ue-mcp 1.2.4 → 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 (243) 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/lean-context.js +8 -3
  34. package/dist/lean-context.js.map +1 -1
  35. package/dist/locking.js +11 -2
  36. package/dist/locking.js.map +1 -1
  37. package/dist/project.d.ts +11 -0
  38. package/dist/project.js +23 -2
  39. package/dist/project.js.map +1 -1
  40. package/dist/python-gate.d.ts +95 -0
  41. package/dist/python-gate.js +175 -0
  42. package/dist/python-gate.js.map +1 -0
  43. package/dist/tool-counts.json +16 -16
  44. package/dist/tools/animation.js +288 -24
  45. package/dist/tools/animation.js.map +1 -1
  46. package/dist/tools/asset.js +79 -14
  47. package/dist/tools/asset.js.map +1 -1
  48. package/dist/tools/audio.js +59 -5
  49. package/dist/tools/audio.js.map +1 -1
  50. package/dist/tools/blueprint.js +29 -12
  51. package/dist/tools/blueprint.js.map +1 -1
  52. package/dist/tools/chooser.js +4 -1
  53. package/dist/tools/chooser.js.map +1 -1
  54. package/dist/tools/editor.js +81 -43
  55. package/dist/tools/editor.js.map +1 -1
  56. package/dist/tools/feedback.js +214 -48
  57. package/dist/tools/feedback.js.map +1 -1
  58. package/dist/tools/foliage.js +27 -1
  59. package/dist/tools/foliage.js.map +1 -1
  60. package/dist/tools/gameplay.js +38 -11
  61. package/dist/tools/gameplay.js.map +1 -1
  62. package/dist/tools/gas.js +17 -5
  63. package/dist/tools/gas.js.map +1 -1
  64. package/dist/tools/landscape.js +22 -7
  65. package/dist/tools/landscape.js.map +1 -1
  66. package/dist/tools/level.js +322 -51
  67. package/dist/tools/level.js.map +1 -1
  68. package/dist/tools/material.js +10 -4
  69. package/dist/tools/material.js.map +1 -1
  70. package/dist/tools/niagara.js +3 -2
  71. package/dist/tools/niagara.js.map +1 -1
  72. package/dist/tools/pcg.js +6 -5
  73. package/dist/tools/pcg.js.map +1 -1
  74. package/dist/tools/project.js +47 -20
  75. package/dist/tools/project.js.map +1 -1
  76. package/dist/types.d.ts +21 -0
  77. package/dist/types.js +46 -3
  78. package/dist/types.js.map +1 -1
  79. package/dist/ue-mcp.default.yml +493 -125
  80. package/package.json +2 -2
  81. package/plugin/ue_mcp_bridge/NATIVE_RUNTIME_VISIBILITY.md +53 -0
  82. package/plugin/ue_mcp_bridge/NATIVE_STATIC_MESH_USAGE_PROGRESS.md +115 -0
  83. package/plugin/ue_mcp_bridge/README_MassHandlers.md +97 -0
  84. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/BridgeServer.cpp +14 -0
  85. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/GameThreadExecutor.cpp +21 -4
  86. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/GameThreadExecutor.h +9 -0
  87. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/HandlerJsonProperty.h +395 -69
  88. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp +79 -3
  89. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.h +25 -0
  90. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRig.cpp +3 -3
  91. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp +3061 -0
  92. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp +961 -0
  93. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp +900 -0
  94. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Pose.cpp +769 -0
  95. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Sequence.cpp +44 -3
  96. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_SkeletalLive.cpp +217 -11
  97. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp +551 -24
  98. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp +735 -0
  99. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.cpp +352 -73
  100. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.h +8 -0
  101. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkProperties.cpp +1 -1
  102. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkRead.cpp +503 -0
  103. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkRead.h +21 -0
  104. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkUpsert.cpp +2 -2
  105. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Enum.cpp +2 -2
  106. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Geometry.cpp +902 -0
  107. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Geometry.h +33 -0
  108. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Import.cpp +478 -134
  109. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Mesh.cpp +5 -5
  110. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_MeshBoolean.cpp +933 -0
  111. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_MeshBoolean.h +35 -0
  112. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Redirectors.cpp +478 -0
  113. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_RenderTarget.cpp +1 -1
  114. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Sockets.cpp +4 -4
  115. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Struct.cpp +2 -2
  116. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Subobject.cpp +319 -0
  117. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AudioHandlers_SoundCue.cpp +3 -3
  118. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers.cpp +482 -35
  119. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers.h +10 -0
  120. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Collision.cpp +502 -0
  121. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Collision.h +43 -0
  122. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Functions.cpp +89 -7
  123. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Graph.cpp +17 -51
  124. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Internal.h +107 -0
  125. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Properties.cpp +10 -10
  126. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Search.cpp +648 -0
  127. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DemoHandlers.cpp +5 -1
  128. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.cpp +252 -1
  129. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.h +10 -2
  130. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp +252 -41
  131. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.h +4 -0
  132. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_Build.cpp +250 -27
  133. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_PIE.cpp +303 -125
  134. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_PIERuntime.cpp +73 -50
  135. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_RuntimeVisibility.cpp +757 -0
  136. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers.cpp +11 -2
  137. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers.h +3 -0
  138. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers_Batch.cpp +419 -0
  139. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers.cpp +39 -161
  140. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers.h +66 -1
  141. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers_BTAuthoring.cpp +1323 -0
  142. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers_BehaviorTree.cpp +1211 -0
  143. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers.cpp +2 -0
  144. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers.h +60 -0
  145. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers_Runtime.cpp +363 -12
  146. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/HandlerEditorState.h +84 -0
  147. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.cpp +1288 -36
  148. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.h +3 -0
  149. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers_Sculpt.cpp +41 -16
  150. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.cpp +959 -213
  151. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.h +66 -0
  152. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_BatchWrite.cpp +1571 -0
  153. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Convert.cpp +316 -0
  154. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Inspection.cpp +280 -0
  155. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_InstanceProjection.cpp +666 -0
  156. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_InstanceProjection_Internal.h +63 -0
  157. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Lights.cpp +43 -12
  158. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_MultiLevel.cpp +13 -47
  159. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_PostProcess.cpp +527 -0
  160. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Query.cpp +1169 -0
  161. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Refresh.cpp +723 -0
  162. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Save.cpp +325 -0
  163. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Spatial.cpp +360 -0
  164. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Trace.cpp +178 -58
  165. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Transient.cpp +337 -0
  166. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Volumes.cpp +25 -12
  167. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_WorldPartition.cpp +10 -9
  168. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_WorldPartitionSettings.cpp +686 -0
  169. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MassHandlers.cpp +467 -0
  170. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MassHandlers.h +22 -0
  171. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers.cpp +452 -175
  172. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers.h +5 -0
  173. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers_Build.cpp +597 -0
  174. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/NiagaraHandlers.cpp +17 -15
  175. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PCGHandlers.cpp +38 -168
  176. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PhysicsHandlers.cpp +39 -30
  177. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers.cpp +392 -78
  178. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers.h +2 -0
  179. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers_Internal.h +83 -0
  180. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SkeletalMeshHandlers.cpp +200 -0
  181. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SkeletalMeshHandlers.h +15 -0
  182. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SplineHandlers.cpp +19 -12
  183. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/StateTreeHandlers.cpp +31 -1
  184. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.cpp +307 -69
  185. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.h +84 -0
  186. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers_Extraction.cpp +8 -41
  187. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers_Properties.cpp +33 -52
  188. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/ActorSelectorTests.cpp +344 -0
  189. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationControlRigTimelineTests.cpp +60 -0
  190. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationPoseTests.cpp +115 -0
  191. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetBulkReadTests.cpp +177 -0
  192. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetDeleteGuardTests.cpp +276 -0
  193. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetPathResolutionTests.cpp +243 -0
  194. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetPropertyPersistTests.cpp +213 -0
  195. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetSubobjectTests.cpp +235 -0
  196. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AutomationRunnerTests.cpp +72 -0
  197. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BehaviorTreeAuthoringTests.cpp +526 -0
  198. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BehaviorTreeNodeTests.cpp +334 -0
  199. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BlueprintHandlerSurfaceTests.cpp +362 -0
  200. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BridgeProtocolTests.cpp +14 -0
  201. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/CollisionQueryTests.cpp +288 -0
  202. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/DataTableRowWriteTests.cpp +453 -0
  203. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/EditorFunctionCallTests.cpp +223 -0
  204. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/FoliageBatchTests.cpp +147 -0
  205. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/GameThreadGateTests.cpp +82 -0
  206. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/GasLiveAttributeTests.cpp +183 -0
  207. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LandscapeSampleTests.cpp +119 -0
  208. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelBatchWriteTests.cpp +176 -0
  209. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelComponentTreeInstanceCountTests.cpp +189 -0
  210. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelConvertBrushesTests.cpp +100 -0
  211. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelInstanceProjectionTests.cpp +265 -0
  212. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelPostProcessOverrideTests.cpp +258 -0
  213. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelQueryComponentsTests.cpp +195 -0
  214. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelRefreshTests.cpp +142 -0
  215. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelSaveReportTests.cpp +125 -0
  216. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelScriptHandlersTests.cpp +42 -0
  217. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelStaticMeshUsageTests.cpp +412 -0
  218. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelTraceWorldScopeTests.cpp +133 -0
  219. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelTransientActorTests.cpp +205 -0
  220. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelWorldPartitionSettingsTests.cpp +168 -0
  221. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MCPInstancedStructPathTestTypes.h +51 -0
  222. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MCPInstancedStructPathTests.cpp +379 -0
  223. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MaterialBuildTests.cpp +238 -0
  224. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MeshBooleanTests.cpp +170 -0
  225. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MeshGeometryTests.cpp +116 -0
  226. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/PackageSaveExtensionTests.cpp +161 -0
  227. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/PackageWriteGuardTests.cpp +253 -0
  228. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/RuntimeVisibilityTests.cpp +143 -0
  229. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerHandlerTests.cpp +660 -0
  230. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerScrubTests.cpp +50 -0
  231. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SkeletonDiffTests.cpp +685 -0
  232. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/WidgetBlueprintResolveTests.cpp +145 -0
  233. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/UE_MCP_Bridge.cpp +80 -0
  234. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerFunctionCall.h +509 -0
  235. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerQuery.h +407 -0
  236. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h +1008 -77
  237. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs +87 -0
  238. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Private/MCPEngineStatus.cpp +152 -14
  239. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Private/Tests/MCPEngineStatusTests.cpp +65 -0
  240. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Public/MCPEngineStatus.h +32 -1
  241. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/UE_MCP_BridgeStatus.Build.cs +2 -0
  242. package/skills/ue-mcp-animation/SKILL.md +154 -0
  243. package/skills/ue-mcp-blueprint/SKILL.md +71 -0
@@ -7,11 +7,15 @@
7
7
  #include "UObject/UObjectIterator.h"
8
8
  #include "UObject/Package.h"
9
9
  #include "UObject/SavePackage.h"
10
+ #include "HAL/FileManager.h"
10
11
  #include "Misc/PackageName.h"
11
12
  #include "Engine/World.h"
12
13
  #include "Engine/Blueprint.h"
13
14
  #include "EngineUtils.h"
14
15
  #include "GameFramework/Actor.h"
16
+ #include "EditorAssetLibrary.h"
17
+ #include "AssetRegistry/AssetRegistryModule.h"
18
+ #include "AssetRegistry/IAssetRegistry.h"
15
19
 
16
20
  // Engine API tiers. One macro per supported minor version, so a gate reads the
17
21
  // same everywhere and nobody writes a second scheme. The supported range is
@@ -135,6 +139,280 @@ inline TSharedPtr<FJsonValue> MCPCheckAssetExists(
135
139
  return TSharedPtr<FJsonValue>();
136
140
  }
137
141
 
142
+ // -- Asset path forms ---------------------------------------------------------
143
+
144
+ /** The forms one asset path can take. `/Game/Foo/DT_Thing` names the package
145
+ * and `/Game/Foo/DT_Thing.DT_Thing` names the asset inside it; both are
146
+ * legitimate ways to address the same asset, asset(search) reports the first
147
+ * one, and #957 was a set of actions that accepted only the second. Deriving
148
+ * both forms once, here, is what lets every action accept either and lets a
149
+ * miss report which form it actually tried. */
150
+ struct FMCPAssetPathForms
151
+ {
152
+ /** Exactly what the caller sent, untouched. */
153
+ FString Input;
154
+ /** `/Game/Foo/DT_Thing` */
155
+ FString PackagePath;
156
+ /** `/Game/Foo/DT_Thing.DT_Thing` */
157
+ FString ObjectPath;
158
+ /** `DT_Thing` */
159
+ FString AssetName;
160
+ /** True when the caller already supplied the object name. */
161
+ bool bInputCarriedObjectName = false;
162
+ };
163
+
164
+ /** Derive every path form from whatever the caller supplied. Accepts the
165
+ * export-text form (`DataTable'/Game/Foo/DT.DT'`), the object path and the
166
+ * bare package path, and tolerates surrounding whitespace. */
167
+ inline FMCPAssetPathForms MCPAssetPathForms(const FString& AssetPath)
168
+ {
169
+ FMCPAssetPathForms Forms;
170
+ Forms.Input = AssetPath;
171
+
172
+ FString Normalized = FPackageName::ExportTextPathToObjectPath(AssetPath);
173
+ Normalized.TrimStartAndEndInline();
174
+ if (Normalized.IsEmpty()) return Forms;
175
+
176
+ Forms.PackagePath = FPackageName::ObjectPathToPackageName(Normalized);
177
+ // ObjectPathToPackageName is the identity on a bare package path, so a
178
+ // longer input is the only thing that can have carried an object name.
179
+ Forms.bInputCarriedObjectName = Normalized.Len() > Forms.PackagePath.Len();
180
+
181
+ if (Forms.bInputCarriedObjectName)
182
+ {
183
+ Forms.ObjectPath = Normalized;
184
+ Forms.AssetName = FPackageName::ObjectPathToObjectName(Normalized);
185
+ }
186
+ else
187
+ {
188
+ // A package holds its asset under the package's own leaf name. This is
189
+ // the convention every content asset follows and the form
190
+ // UEditorAssetLibrary::LoadAsset builds internally.
191
+ if (!Forms.PackagePath.Split(TEXT("/"), nullptr, &Forms.AssetName,
192
+ ESearchCase::CaseSensitive, ESearchDir::FromEnd))
193
+ {
194
+ Forms.AssetName = Forms.PackagePath;
195
+ }
196
+ Forms.ObjectPath = Forms.AssetName.IsEmpty()
197
+ ? Forms.PackagePath
198
+ : Forms.PackagePath + TEXT(".") + Forms.AssetName;
199
+ }
200
+ return Forms;
201
+ }
202
+
203
+ /** Load the asset at `AssetPath`, the way asset(read) does.
204
+ *
205
+ * UEditorAssetLibrary::LoadAsset is the usual entry point, but it validates
206
+ * the path through EditorScriptingHelpers before it loads anything and
207
+ * answers null for path forms it does not accept, and for any call made
208
+ * while the editor is in play-in-editor. asset(read) has always had a
209
+ * LoadObject fallback for exactly that reason, and the type-specific readers
210
+ * did not: read_datatable, get_datatable_row and export all reported
211
+ * "Asset not found" or "Asset is not a DataTable" for assets that
212
+ * asset(read) opened and correctly named as DataTables (#930).
213
+ *
214
+ * This lives here rather than as a copy per handler file: the asset handlers
215
+ * share one unity blob, and a second copy would either collide at compile
216
+ * time or drift into resolving differently from its neighbours. */
217
+ inline UObject* MCPLoadAssetObject(const FString& AssetPath)
218
+ {
219
+ if (AssetPath.IsEmpty()) return nullptr;
220
+
221
+ const FMCPAssetPathForms Forms = MCPAssetPathForms(AssetPath);
222
+ if (Forms.ObjectPath.IsEmpty()) return nullptr;
223
+
224
+ // An object already in memory is the answer, and running a path validator
225
+ // over it can only turn a good answer into a null and an error log. The
226
+ // object path is what this step needs: a path with no "." names a package,
227
+ // and returning the UPackage in place of the asset would be a worse answer
228
+ // than not looking, so the derived object path stands in for it.
229
+ if (UObject* Loaded = FindObject<UObject>(nullptr, *Forms.ObjectPath))
230
+ {
231
+ if (!Loaded->IsA<UPackage>()) return Loaded;
232
+ }
233
+
234
+ if (UObject* ViaEditorLibrary = UEditorAssetLibrary::LoadAsset(AssetPath))
235
+ {
236
+ return ViaEditorLibrary;
237
+ }
238
+
239
+ // #957: the caller's own form is tried first so nothing that used to work
240
+ // stops working, and the derived object path second. A bare package path
241
+ // for an asset that is not loaded yet is the case that used to answer
242
+ // "Asset not found" for an asset asset(search) had just reported by that
243
+ // exact string, because the load stopped at a form the loader would not
244
+ // resolve on its own.
245
+ if (UObject* ViaInput = LoadObject<UObject>(nullptr, *AssetPath))
246
+ {
247
+ if (!ViaInput->IsA<UPackage>()) return ViaInput;
248
+ }
249
+ if (!Forms.bInputCarriedObjectName)
250
+ {
251
+ if (UObject* ViaObjectPath = LoadObject<UObject>(nullptr, *Forms.ObjectPath))
252
+ {
253
+ if (!ViaObjectPath->IsA<UPackage>()) return ViaObjectPath;
254
+ }
255
+ }
256
+ return nullptr;
257
+ }
258
+
259
+ /** Look an asset path up in the Asset Registry without loading anything.
260
+ * Tries the exact object path first, then any asset the registry holds in
261
+ * that package, which is what distinguishes "the package has an asset under
262
+ * a different name" from "there is nothing there at all". */
263
+ inline FAssetData MCPFindAssetDataForPath(const FMCPAssetPathForms& Forms)
264
+ {
265
+ if (Forms.PackagePath.IsEmpty()) return FAssetData();
266
+
267
+ FAssetRegistryModule* Module = FModuleManager::GetModulePtr<FAssetRegistryModule>(TEXT("AssetRegistry"));
268
+ if (!Module) return FAssetData();
269
+ IAssetRegistry& Registry = Module->Get();
270
+
271
+ if (!Forms.ObjectPath.IsEmpty())
272
+ {
273
+ const FAssetData Exact = Registry.GetAssetByObjectPath(FSoftObjectPath(Forms.ObjectPath));
274
+ if (Exact.IsValid()) return Exact;
275
+ }
276
+
277
+ TArray<FAssetData> InPackage;
278
+ Registry.GetAssetsByPackageName(FName(*Forms.PackagePath), InPackage);
279
+ for (const FAssetData& Candidate : InPackage)
280
+ {
281
+ if (Candidate.IsValid()) return Candidate;
282
+ }
283
+ return FAssetData();
284
+ }
285
+
286
+ /** The answer for a path MCPLoadAssetObject could not resolve.
287
+ *
288
+ * A bare "Asset not found" is the same sentence for a path that names nothing,
289
+ * a path whose shape the loader rejects, and an asset whose package will not
290
+ * open, and #957 and #913 were both reported as missing assets that were not
291
+ * missing at all. This names the forms that were tried, says whether the
292
+ * package is on disk, reports what the Asset Registry knows without loading
293
+ * anything, and, when the registry holds the asset under a different object
294
+ * path, names the form that would have worked. */
295
+ inline TSharedPtr<FJsonValue> MCPAssetNotFoundError(const FString& AssetPath, const FString& Context = FString())
296
+ {
297
+ const FMCPAssetPathForms Forms = MCPAssetPathForms(AssetPath);
298
+ const FAssetData Found = MCPFindAssetDataForPath(Forms);
299
+ const bool bPackageOnDisk = !Forms.PackagePath.IsEmpty()
300
+ && FPackageName::IsValidLongPackageName(Forms.PackagePath)
301
+ && FPackageName::DoesPackageExist(Forms.PackagePath);
302
+
303
+ // Context, when given, names what the path was supposed to be, so the
304
+ // sentence reads "Source asset not found: ..." rather than the generic form.
305
+ const FString Prefix = FString::Printf(
306
+ TEXT("%s not found: '%s'."), Context.IsEmpty() ? TEXT("Asset") : *Context, *AssetPath);
307
+
308
+ FString RegistryObjectPath;
309
+ FString RegistryClass;
310
+ if (Found.IsValid())
311
+ {
312
+ RegistryObjectPath = Found.GetSoftObjectPath().ToString();
313
+ RegistryClass = Found.AssetClassPath.GetAssetName().ToString();
314
+ }
315
+
316
+ FString Reason;
317
+ FString Suggestion;
318
+ FString Message;
319
+ if (Found.IsValid() && !RegistryObjectPath.Equals(Forms.ObjectPath, ESearchCase::IgnoreCase))
320
+ {
321
+ // The package holds an asset, just not under the name the path form
322
+ // implies. Naming it is the difference between a dead end and a fix.
323
+ Reason = TEXT("pathShape");
324
+ Suggestion = RegistryObjectPath;
325
+ Message = FString::Printf(
326
+ TEXT("%s The Asset Registry has '%s' (%s) in package '%s'. Pass that object path."),
327
+ *Prefix, *RegistryObjectPath, *RegistryClass, *Forms.PackagePath);
328
+ }
329
+ else if (Found.IsValid())
330
+ {
331
+ Reason = TEXT("loadFailed");
332
+ Message = FString::Printf(
333
+ TEXT("%s The Asset Registry lists '%s' (%s), so the asset exists but the package would not open. ")
334
+ TEXT("It may be corrupt, or reference a class the editor cannot resolve."),
335
+ *Prefix, *RegistryObjectPath, *RegistryClass);
336
+ }
337
+ else if (bPackageOnDisk)
338
+ {
339
+ Reason = TEXT("notIndexed");
340
+ Suggestion = Forms.ObjectPath;
341
+ Message = FString::Printf(
342
+ TEXT("%s A package file exists at '%s' but the Asset Registry holds no asset in it, ")
343
+ TEXT("so it may still be scanning. Tried '%s' and '%s'."),
344
+ *Prefix, *Forms.PackagePath, *AssetPath, *Forms.ObjectPath);
345
+ }
346
+ else
347
+ {
348
+ Reason = TEXT("missing");
349
+ Message = FString::Printf(
350
+ TEXT("%s No package exists at '%s' and the Asset Registry has no entry for it. ")
351
+ TEXT("Tried '%s' and '%s'."),
352
+ *Prefix, *Forms.PackagePath, *AssetPath, *Forms.ObjectPath);
353
+ }
354
+
355
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
356
+ Obj->SetBoolField(TEXT("success"), false);
357
+ Obj->SetStringField(TEXT("error"), Message);
358
+ Obj->SetStringField(TEXT("assetPath"), AssetPath);
359
+ Obj->SetStringField(TEXT("packagePath"), Forms.PackagePath);
360
+ Obj->SetStringField(TEXT("objectPath"), Forms.ObjectPath);
361
+ Obj->SetBoolField(TEXT("packageExistsOnDisk"), bPackageOnDisk);
362
+ Obj->SetBoolField(TEXT("registryMatched"), Found.IsValid());
363
+ Obj->SetStringField(TEXT("reason"), Reason);
364
+ if (Found.IsValid())
365
+ {
366
+ Obj->SetStringField(TEXT("registryObjectPath"), RegistryObjectPath);
367
+ Obj->SetStringField(TEXT("registryClass"), RegistryClass);
368
+ }
369
+ if (!Suggestion.IsEmpty())
370
+ {
371
+ Obj->SetStringField(TEXT("suggestedPath"), Suggestion);
372
+ }
373
+ return MakeShared<FJsonValueObject>(Obj);
374
+ }
375
+
376
+ /** Resolve an asset path or hand back the diagnostic error. Returns nullptr
377
+ * with OutError set on a miss, so a handler reads as
378
+ * `if (!Asset) return OutError;`. */
379
+ inline UObject* MCPRequireAssetObject(
380
+ const FString& AssetPath,
381
+ TSharedPtr<FJsonValue>& OutError,
382
+ const FString& Context = FString())
383
+ {
384
+ UObject* Asset = MCPLoadAssetObject(AssetPath);
385
+ if (!Asset)
386
+ {
387
+ OutError = MCPAssetNotFoundError(AssetPath, Context);
388
+ }
389
+ return Asset;
390
+ }
391
+
392
+ /** The answer for a path that resolved to something of the wrong type.
393
+ * Distinct from a miss on purpose: "not a DataTable" used to be the sentence
394
+ * a caller saw when the path resolved to nothing at all. */
395
+ inline TSharedPtr<FJsonValue> MCPAssetWrongTypeError(
396
+ const FString& AssetPath,
397
+ const UObject* Found,
398
+ const TCHAR* ExpectedType)
399
+ {
400
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
401
+ Obj->SetBoolField(TEXT("success"), false);
402
+ Obj->SetStringField(TEXT("error"), FString::Printf(
403
+ TEXT("Asset is not a %s: '%s' (found a %s)."),
404
+ ExpectedType, *AssetPath,
405
+ Found ? *Found->GetClass()->GetName() : TEXT("null")));
406
+ Obj->SetStringField(TEXT("assetPath"), AssetPath);
407
+ Obj->SetStringField(TEXT("expectedClass"), ExpectedType);
408
+ if (Found)
409
+ {
410
+ Obj->SetStringField(TEXT("foundClass"), Found->GetClass()->GetName());
411
+ Obj->SetStringField(TEXT("objectPath"), Found->GetPathName());
412
+ }
413
+ return MakeShared<FJsonValueObject>(Obj);
414
+ }
415
+
138
416
  /** Protected mount guardrail. Engine-shipped content (/Engine/, /Script/,
139
417
  * /Memory/, /Temp/) and Verse runtime classes must never be mutated through
140
418
  * the bridge: UEditorAssetLibrary::DeleteAsset will happily destroy files
@@ -153,18 +431,30 @@ inline bool MCPIsProtectedAssetPath(const FString& Path)
153
431
  FString Normalized = Path;
154
432
  Normalized.TrimStartAndEndInline();
155
433
  if (Normalized.IsEmpty()) return false;
434
+ Normalized = FPackageName::ExportTextPathToObjectPath(Normalized);
435
+ Normalized.TrimStartAndEndInline();
156
436
  // Tolerate the surface form, which may arrive without a leading slash.
157
437
  if (!Normalized.StartsWith(TEXT("/"))) Normalized = TEXT("/") + Normalized;
158
438
  const FString Lower = Normalized.ToLower();
159
- if (Lower.StartsWith(TEXT("/engine/"))) return true;
160
- if (Lower.StartsWith(TEXT("/memory/"))) return true;
161
- if (Lower.StartsWith(TEXT("/temp/"))) return true;
439
+ if (Lower == TEXT("/engine") || Lower.StartsWith(TEXT("/engine/"))) return true;
440
+ if (Lower == TEXT("/memory") || Lower.StartsWith(TEXT("/memory/"))) return true;
441
+ if (Lower == TEXT("/temp") || Lower.StartsWith(TEXT("/temp/"))) return true;
162
442
  // Verse runtime objects surface as /Script/CoreUObject.* etc, so /Script/
163
443
  // is rejected wherever it appears, not just as a prefix.
164
- if (Lower.Contains(TEXT("/script/"))) return true;
444
+ if (Lower == TEXT("/script") || Lower.Contains(TEXT("/script/"))) return true;
165
445
  return false;
166
446
  }
167
447
 
448
+ /** The refusal a protected mount produces. Beside the rule it enforces, so a
449
+ * handler cannot pair MCPIsProtectedAssetPath with a message of its own that
450
+ * says something slightly different. */
451
+ inline TSharedPtr<FJsonValue> MCPProtectedPathError(const FString& Path)
452
+ {
453
+ return MCPError(FString::Printf(
454
+ TEXT("Refusing to mutate protected mount: %s. Engine, /Script/, /Memory/, /Temp/ are read-only via the bridge."),
455
+ *Path));
456
+ }
457
+
168
458
  /** Emit the standard delete_asset rollback record on a create result. */
169
459
  inline void MCPSetDeleteAssetRollback(TSharedPtr<FJsonObject> Result, const FString& AssetPath)
170
460
  {
@@ -173,76 +463,236 @@ inline void MCPSetDeleteAssetRollback(TSharedPtr<FJsonObject> Result, const FStr
173
463
  MCPSetRollback(Result, TEXT("delete_asset"), Payload);
174
464
  }
175
465
 
176
- /** Find an actor by GetActorLabel(). Returns nullptr on miss. Centralises
177
- * the iterator-based lookup that previously lived as a private static in
178
- * several handler translation units. */
179
- inline AActor* FindActorByLabel(UWorld* World, const FString& Label)
466
+ // ── Actor selection ──────────────────────────────────────────────────────────
467
+ //
468
+ // Editor labels are NOT unique. A copy-pasted Blueprint gives every copy the
469
+ // same label, and a label lookup that answers with "the first actor the
470
+ // iterator reached" is a coin flip decided by streaming order. #983 is what
471
+ // that costs: several actors labelled BP_SnappyRoad2, a write aimed at the one
472
+ // the user had selected, and the edit landing on a road at the other end of
473
+ // the map with a success response and nothing to suggest a choice was made.
474
+ //
475
+ // So there is one resolver, and it refuses rather than guesses:
476
+ //
477
+ // * 'actorPath' is the unambiguous selector and wins whenever it is given.
478
+ // * A label naming more than one actor is an error listing every candidate
479
+ // and its actorPath, so the caller can retry precisely.
480
+ // * There is no "just pick one" override. The precise selector already
481
+ // exists, so a caller who wants a specific one of the duplicates has a
482
+ // correct answer, and a caller who wants all of them is asking for a
483
+ // different, plural action. A flag that picks an arbitrary actor out of a
484
+ // set the caller could not tell apart is the same silent wrong write with
485
+ // a name on it.
486
+ //
487
+ // The plural need is served by MCPCollectActorsByToken, which returns every
488
+ // match and is what the ignore-list and reference-list parameters use.
489
+
490
+ /** How a selector token is allowed to match an actor. */
491
+ enum class EMCPActorMatch : uint8
492
+ {
493
+ /** Editor label only. */
494
+ Label,
495
+ /** Editor label, then the internal UObject name. */
496
+ LabelOrName,
497
+ /** Editor label, then internal name, then the full object path. */
498
+ LabelNameOrPath,
499
+ };
500
+
501
+ /** The actor in World whose full object path is Path, or nullptr. Paths are
502
+ * unique, so there is never a choice to make. Accepts the export-text form
503
+ * (Actor'/Game/...') and falls back to a case-insensitive compare, because a
504
+ * path that came back from one action and was pasted into another is the
505
+ * whole point of having it. */
506
+ inline AActor* MCPFindActorByPath(UWorld* World, const FString& Path)
180
507
  {
181
- if (!World) return nullptr;
508
+ if (!World || Path.IsEmpty()) return nullptr;
509
+ FString Wanted = FPackageName::ExportTextPathToObjectPath(Path);
510
+ Wanted.TrimStartAndEndInline();
511
+ if (Wanted.IsEmpty()) return nullptr;
512
+ AActor* CaseInsensitive = nullptr;
182
513
  for (TActorIterator<AActor> It(World); It; ++It)
183
514
  {
184
- if (It->GetActorLabel() == Label) return *It;
515
+ AActor* A = *It;
516
+ if (!IsValid(A)) continue;
517
+ const FString Actual = A->GetPathName();
518
+ if (Actual.Equals(Wanted, ESearchCase::CaseSensitive)) return A;
519
+ if (!CaseInsensitive && Actual.Equals(Wanted, ESearchCase::IgnoreCase)) CaseInsensitive = A;
185
520
  }
186
- return nullptr;
521
+ return CaseInsensitive;
187
522
  }
188
523
 
189
- /** Find an actor by either editor label or internal UObject name. Used by
190
- * PIE / runtime handlers where callers may pass either form. */
191
- inline AActor* FindActorByLabelOrName(UWorld* World, const FString& LabelOrName)
524
+ /** Every actor the token names under Match, sorted by object path so the order
525
+ * is the same on every run rather than whatever the actor iterator happened
526
+ * to produce that time.
527
+ *
528
+ * The tiers do not blend: a token that is one actor's label and another
529
+ * actor's internal name resolves to the label match alone, because the label
530
+ * is what the outliner shows and what a caller types. Name and path answer
531
+ * only when the label tier found nothing (#806). */
532
+ inline void MCPCollectActorsByToken(
533
+ UWorld* World,
534
+ const FString& Token,
535
+ EMCPActorMatch Match,
536
+ TArray<AActor*>& OutMatches)
192
537
  {
193
- if (!World) return nullptr;
538
+ OutMatches.Reset();
539
+ if (!World || Token.IsEmpty()) return;
540
+
541
+ TArray<AActor*> ByName;
542
+ TArray<AActor*> ByPath;
194
543
  for (TActorIterator<AActor> It(World); It; ++It)
195
544
  {
196
- if (It->GetActorLabel() == LabelOrName || It->GetName() == LabelOrName) return *It;
545
+ AActor* A = *It;
546
+ if (!IsValid(A)) continue;
547
+ if (A->GetActorLabel() == Token) { OutMatches.Add(A); continue; }
548
+ if (Match != EMCPActorMatch::Label && A->GetName() == Token) { ByName.Add(A); continue; }
549
+ if (Match == EMCPActorMatch::LabelNameOrPath && A->GetPathName() == Token) { ByPath.Add(A); }
197
550
  }
198
- return nullptr;
551
+ if (OutMatches.Num() == 0) OutMatches = MoveTemp(ByName);
552
+ if (OutMatches.Num() == 0) OutMatches = MoveTemp(ByPath);
553
+
554
+ OutMatches.Sort([](const AActor& A, const AActor& B)
555
+ {
556
+ return A.GetPathName().Compare(B.GetPathName()) < 0;
557
+ });
199
558
  }
200
559
 
201
- /** Find an actor by either editor label or full object path. Used by
202
- * get_actor_details / get_component_tree which accept either form. */
203
- inline AActor* FindActorByLabelOrPath(UWorld* World, const FString& Label, const FString& Path)
560
+ /** Which tier of MCPCollectActorsByToken produced a match, for the message. */
561
+ inline const TCHAR* MCPDescribeActorMatchTier(const FString& Token, AActor* Match)
204
562
  {
205
- if (!World) return nullptr;
206
- const bool bHasLabel = !Label.IsEmpty();
207
- const bool bHasPath = !Path.IsEmpty();
208
- if (!bHasLabel && !bHasPath) return nullptr;
209
- for (TActorIterator<AActor> It(World); It; ++It)
563
+ if (Match && Match->GetActorLabel() == Token) return TEXT("editor label");
564
+ if (Match && Match->GetName() == Token) return TEXT("internal object name");
565
+ return TEXT("object path");
566
+ }
567
+
568
+ /** One candidate row in an ambiguity refusal: enough to tell two same-labelled
569
+ * actors apart without a follow-up call, plus the actorPath to retry with. */
570
+ inline TSharedPtr<FJsonObject> MCPDescribeActorCandidate(AActor* Actor)
571
+ {
572
+ TSharedPtr<FJsonObject> Row = MakeShared<FJsonObject>();
573
+ if (!Actor) return Row;
574
+ Row->SetStringField(TEXT("actorPath"), Actor->GetPathName());
575
+ Row->SetStringField(TEXT("actorLabel"), Actor->GetActorLabel());
576
+ Row->SetStringField(TEXT("actorName"), Actor->GetName());
577
+ Row->SetStringField(TEXT("actorClass"), Actor->GetClass()->GetName());
578
+ Row->SetStringField(TEXT("folderPath"), Actor->GetFolderPath().ToString());
579
+ const FVector Loc = Actor->GetActorLocation();
580
+ TSharedPtr<FJsonObject> LocObj = MakeShared<FJsonObject>();
581
+ LocObj->SetNumberField(TEXT("x"), Loc.X);
582
+ LocObj->SetNumberField(TEXT("y"), Loc.Y);
583
+ LocObj->SetNumberField(TEXT("z"), Loc.Z);
584
+ Row->SetObjectField(TEXT("location"), LocObj);
585
+ return Row;
586
+ }
587
+
588
+ /** The refusal an ambiguous selector produces. Lists every candidate and its
589
+ * actorPath, so the retry is a copy of one field rather than a hunt back
590
+ * through get_outliner. */
591
+ inline TSharedPtr<FJsonValue> MCPAmbiguousActorError(
592
+ const FString& Token,
593
+ const TCHAR* LabelKey,
594
+ const TCHAR* PathKey,
595
+ const TCHAR* MatchedBy,
596
+ const TArray<AActor*>& Candidates)
597
+ {
598
+ const int32 Cap = 25;
599
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
600
+ Obj->SetBoolField(TEXT("success"), false);
601
+ Obj->SetStringField(TEXT("error"), FString::Printf(
602
+ TEXT("Ambiguous actor selector: '%s' is the %s of %d actors. Editor labels are not unique, so this call refuses rather than picking one of them. Retry with '%s' set to one of the candidate paths below."),
603
+ *Token, MatchedBy, Candidates.Num(), PathKey));
604
+ Obj->SetBoolField(TEXT("ambiguous"), true);
605
+ Obj->SetStringField(TEXT("selector"), LabelKey);
606
+ Obj->SetStringField(TEXT("selectorValue"), Token);
607
+ Obj->SetStringField(TEXT("matchedBy"), MatchedBy);
608
+ Obj->SetNumberField(TEXT("matchCount"), Candidates.Num());
609
+ TArray<TSharedPtr<FJsonValue>> Rows;
610
+ for (int32 Index = 0; Index < Candidates.Num() && Index < Cap; ++Index)
210
611
  {
211
- if (bHasPath && It->GetPathName() == Path) return *It;
212
- if (bHasLabel && It->GetActorLabel() == Label) return *It;
612
+ Rows.Add(MakeShared<FJsonValueObject>(MCPDescribeActorCandidate(Candidates[Index])));
213
613
  }
214
- return nullptr;
614
+ Obj->SetArrayField(TEXT("candidates"), Rows);
615
+ if (Candidates.Num() > Cap) Obj->SetBoolField(TEXT("candidatesTruncated"), true);
616
+ return MakeShared<FJsonValueObject>(Obj);
215
617
  }
216
618
 
217
- /** Three-way actor lookup against the placed instances in a world: label
218
- * first, then internal object name, then full path. Used by
219
- * EditorHandlers_PIE invoke_function which accepts any of the three.
220
- *
221
- * #806: the priority is fixed rather than "first actor that matches any of
222
- * the three", because a token can be one actor's label and another actor's
223
- * internal name at the same time, and which one won then depended on level
224
- * iteration order. The label is what the outliner shows and what callers
225
- * pass, so it decides outright; name and path only resolve the misses. */
226
- inline AActor* FindActorByLabelNameOrPath(UWorld* World, const FString& Token)
619
+ /** True when a resolver failure was a refusal to choose rather than a miss.
620
+ * An action that treats an absent actor as a no-op still has to fail on an
621
+ * ambiguous one: "already deleted" is the wrong answer when three actors
622
+ * carry the label and none of them was touched. */
623
+ inline bool MCPIsAmbiguousActorError(const TSharedPtr<FJsonValue>& Error)
227
624
  {
228
- if (!World || Token.IsEmpty()) return nullptr;
229
- AActor* NameMatch = nullptr;
230
- AActor* PathMatch = nullptr;
231
- for (TActorIterator<AActor> It(World); It; ++It)
625
+ if (!Error.IsValid() || Error->Type != EJson::Object) return false;
626
+ const TSharedPtr<FJsonObject> Obj = Error->AsObject();
627
+ bool bAmbiguous = false;
628
+ return Obj.IsValid() && Obj->TryGetBoolField(TEXT("ambiguous"), bAmbiguous) && bAmbiguous;
629
+ }
630
+
631
+ // FindActorByLabel, FindActorByLabelOrName, FindActorByLabelOrPath and
632
+ // FindActorByLabelNameOrPath each answered a duplicate label with the first
633
+ // match the actor iterator produced, which is the silent wrong write #983
634
+ // reported, and four spellings of one search is how the rules drift apart.
635
+ // Nothing in THIS plugin calls them any more: core goes through
636
+ // MCPResolveActor, or MCPCollectActorsByToken where the plural answer is the
637
+ // correct one.
638
+ //
639
+ // They survive as compatibility shims because this is a PUBLIC header shipped
640
+ // to plugin authors, and deleting them outright is a breaking change for every
641
+ // plugin built against it. PIE_Studio calls FindActorByLabelOrName today and
642
+ // broke the moment they went; a third-party plugin nobody here can see would
643
+ // have broken the same way, and only after publishing.
644
+ //
645
+ // They now share the consolidated search, so they cannot drift from it, and
646
+ // they keep first-match semantics because that is the contract callers already
647
+ // have. New code should use MCPResolveActor, which refuses an ambiguous label
648
+ // rather than picking one.
649
+
650
+ /** Legacy: first actor whose editor label matches. Prefer MCPResolveActor. */
651
+ inline AActor* FindActorByLabel(UWorld* World, const FString& Label)
652
+ {
653
+ TArray<AActor*> Matches;
654
+ MCPCollectActorsByToken(World, Label, EMCPActorMatch::Label, Matches);
655
+ return Matches.Num() > 0 ? Matches[0] : nullptr;
656
+ }
657
+
658
+ /** Legacy: first actor matching an editor label or internal name.
659
+ * Prefer MCPResolveActor. */
660
+ inline AActor* FindActorByLabelOrName(UWorld* World, const FString& LabelOrName)
661
+ {
662
+ TArray<AActor*> Matches;
663
+ MCPCollectActorsByToken(World, LabelOrName, EMCPActorMatch::LabelOrName, Matches);
664
+ return Matches.Num() > 0 ? Matches[0] : nullptr;
665
+ }
666
+
667
+ /** Legacy: an actor by label, or by full object path when the label is empty.
668
+ * Prefer MCPResolveActor. */
669
+ inline AActor* FindActorByLabelOrPath(UWorld* World, const FString& Label, const FString& Path)
670
+ {
671
+ if (!Path.IsEmpty())
232
672
  {
233
- AActor* A = *It;
234
- if (!IsValid(A)) continue;
235
- if (A->GetActorLabel() == Token) return A;
236
- if (!NameMatch && A->GetName() == Token) NameMatch = A;
237
- if (!PathMatch && A->GetPathName() == Token) PathMatch = A;
673
+ if (AActor* ByPath = MCPFindActorByPath(World, Path)) return ByPath;
238
674
  }
239
- return NameMatch ? NameMatch : PathMatch;
675
+ if (Label.IsEmpty()) return nullptr;
676
+ return FindActorByLabel(World, Label);
677
+ }
678
+
679
+ /** Legacy: first actor matching a label, internal name or object path.
680
+ * Prefer MCPResolveActor. */
681
+ inline AActor* FindActorByLabelNameOrPath(UWorld* World, const FString& Token)
682
+ {
683
+ TArray<AActor*> Matches;
684
+ MCPCollectActorsByToken(World, Token, EMCPActorMatch::LabelNameOrPath, Matches);
685
+ return Matches.Num() > 0 ? Matches[0] : nullptr;
240
686
  }
241
687
 
242
688
  /** Build the "no such actor" message for a failed label/name/path lookup.
243
689
  * Names what was searched and offers the labels that contain the token, so a
244
690
  * caller that guessed a label sees the real one instead of a bare miss. */
245
- inline FString MCPDescribeActorLookupMiss(UWorld* World, const FString& Token, const FString& WorldLabel)
691
+ inline FString MCPDescribeActorLookupMiss(
692
+ UWorld* World,
693
+ const FString& Token,
694
+ const FString& WorldLabel,
695
+ EMCPActorMatch Match = EMCPActorMatch::LabelNameOrPath)
246
696
  {
247
697
  int32 ActorCount = 0;
248
698
  TArray<FString> Near;
@@ -259,9 +709,16 @@ inline FString MCPDescribeActorLookupMiss(UWorld* World, const FString& Token, c
259
709
  }
260
710
  }
261
711
  }
712
+ // Name what was actually searched. Claiming a name and path sweep that a
713
+ // label-only action never ran sends a caller looking for a typo in the
714
+ // wrong field.
715
+ const TCHAR* Searched =
716
+ Match == EMCPActorMatch::Label ? TEXT("by editor label")
717
+ : Match == EMCPActorMatch::LabelOrName ? TEXT("by editor label, then by internal object name")
718
+ : TEXT("by editor label, then by internal object name, then by full object path");
262
719
  FString Msg = FString::Printf(
263
- TEXT("Actor '%s' not found in the %s world. Searched every placed actor by editor label, then by internal object name, then by full object path (%d actors)."),
264
- *Token, *WorldLabel, ActorCount);
720
+ TEXT("Actor '%s' not found in the %s world. Searched every placed actor %s (%d actors). Pass actorPath for an exact object path when a label is ambiguous or absent."),
721
+ *Token, *WorldLabel, Searched, ActorCount);
265
722
  if (Near.Num() > 0)
266
723
  {
267
724
  Msg += FString::Printf(TEXT(" Labels containing that text: [%s]."), *FString::Join(Near, TEXT(", ")));
@@ -270,12 +727,138 @@ inline FString MCPDescribeActorLookupMiss(UWorld* World, const FString& Token, c
270
727
  return Msg;
271
728
  }
272
729
 
730
+ /** Which parameters carry the actor selector for one action, and how far the
731
+ * label token is allowed to reach.
732
+ *
733
+ * Handlers that name their actor something other than 'actorLabel' pass the
734
+ * pair explicitly. The convention is that the path key is the label key with
735
+ * its "Label" suffix swapped for "Path" (childLabel / childPath), so a caller
736
+ * can guess it correctly. */
737
+ struct FMCPActorSelector
738
+ {
739
+ /** Parameter carrying the editor label (or the label/name/path token). */
740
+ const TCHAR* LabelKey = TEXT("actorLabel");
741
+ /** Parameter carrying the unambiguous full object path. */
742
+ const TCHAR* PathKey = TEXT("actorPath");
743
+ /** A second spelling of the label parameter, for actions that shipped
744
+ * with two (get_relative_transform takes 'target' or 'targetLabel').
745
+ * Read only when LabelKey is absent. */
746
+ const TCHAR* AltLabelKey = nullptr;
747
+ /** How far LabelKey's value is allowed to reach. */
748
+ EMCPActorMatch Match = EMCPActorMatch::Label;
749
+ /** When false, an absent selector is not an error: the resolver returns
750
+ * nullptr with OutError left unset and the caller decides what that
751
+ * means (an optional target, or a second selection route). */
752
+ bool bRequired = true;
753
+ /** Names the world in the miss message: "editor", "PIE". */
754
+ const TCHAR* WorldLabel = TEXT("editor");
755
+ };
756
+
757
+ /** Resolve one actor from an already-extracted token. Returns nullptr and
758
+ * writes OutError on a miss or on ambiguity; the caller returns OutError
759
+ * unchanged. Used where the token did not come from a parameter of its own
760
+ * (a list entry, a fixed label). */
761
+ inline AActor* MCPResolveActorToken(
762
+ UWorld* World,
763
+ const FString& Token,
764
+ TSharedPtr<FJsonValue>& OutError,
765
+ const FMCPActorSelector& Selector = FMCPActorSelector())
766
+ {
767
+ OutError.Reset();
768
+ if (!World)
769
+ {
770
+ OutError = MCPError(TEXT("Editor world not available"));
771
+ return nullptr;
772
+ }
773
+ TArray<AActor*> Matches;
774
+ MCPCollectActorsByToken(World, Token, Selector.Match, Matches);
775
+ if (Matches.Num() == 1) return Matches[0];
776
+ if (Matches.Num() > 1)
777
+ {
778
+ OutError = MCPAmbiguousActorError(
779
+ Token, Selector.LabelKey, Selector.PathKey,
780
+ MCPDescribeActorMatchTier(Token, Matches[0]), Matches);
781
+ return nullptr;
782
+ }
783
+ OutError = MCPError(MCPDescribeActorLookupMiss(World, Token, Selector.WorldLabel, Selector.Match));
784
+ return nullptr;
785
+ }
786
+
787
+ /** THE actor resolver. Reads the unambiguous path selector first, then the
788
+ * label, and refuses when the label names more than one actor.
789
+ *
790
+ * A path that names nothing is an error rather than a quiet fall-through to
791
+ * the label: the path is the precise selector, and demoting a precise miss to
792
+ * a fuzzy hit is how the wrong actor gets edited in the first place.
793
+ *
794
+ * Returns nullptr on every failure with OutError carrying the response to
795
+ * return. When Selector.bRequired is false and neither key was supplied,
796
+ * returns nullptr with OutError unset. */
797
+ inline AActor* MCPResolveActor(
798
+ UWorld* World,
799
+ const TSharedPtr<FJsonObject>& Params,
800
+ TSharedPtr<FJsonValue>& OutError,
801
+ const FMCPActorSelector& Selector = FMCPActorSelector())
802
+ {
803
+ OutError.Reset();
804
+
805
+ FString Path;
806
+ FString Token;
807
+ if (Params.IsValid())
808
+ {
809
+ Params->TryGetStringField(Selector.PathKey, Path);
810
+ Params->TryGetStringField(Selector.LabelKey, Token);
811
+ if (Token.IsEmpty() && Selector.AltLabelKey)
812
+ {
813
+ Params->TryGetStringField(Selector.AltLabelKey, Token);
814
+ }
815
+ }
816
+ Path.TrimStartAndEndInline();
817
+ Token.TrimStartAndEndInline();
818
+
819
+ if (Path.IsEmpty() && Token.IsEmpty())
820
+ {
821
+ if (Selector.bRequired)
822
+ {
823
+ OutError = MCPError(FString::Printf(
824
+ TEXT("Missing required parameter '%s' (or '%s'). Editor labels are not unique, so '%s' is the selector to prefer when you have one."),
825
+ Selector.LabelKey, Selector.PathKey, Selector.PathKey));
826
+ }
827
+ return nullptr;
828
+ }
829
+
830
+ if (!World)
831
+ {
832
+ OutError = MCPError(TEXT("Editor world not available"));
833
+ return nullptr;
834
+ }
835
+
836
+ if (!Path.IsEmpty())
837
+ {
838
+ if (AActor* ByPath = MCPFindActorByPath(World, Path)) return ByPath;
839
+ OutError = MCPError(FString::Printf(
840
+ TEXT("No actor at '%s' in the %s world. Object paths look like /Game/Maps/Map.Map:PersistentLevel.Actor_0; level(get_outliner) reports the real one for every actor."),
841
+ *Path, Selector.WorldLabel));
842
+ return nullptr;
843
+ }
844
+
845
+ return MCPResolveActorToken(World, Token, OutError, Selector);
846
+ }
847
+
273
848
  /** Spawn-by-label idempotency check. If World already has an actor with the
274
849
  * given Label, returns a fully-formed "already existed" result the caller
275
850
  * can return directly (or an MCPError when OnConflict == "error"). When
276
851
  * Label is empty or no match exists, returns an unset shared pointer so the
277
852
  * caller proceeds to spawn. Mirrors MCPCheckAssetExists's contract for
278
- * in-world actors. */
853
+ * in-world actors.
854
+ *
855
+ * #983: this asks "does this label already name something", so several
856
+ * matches is an answer rather than a refusal, and refusing here would break
857
+ * a rerun of a spawn that is meant to be idempotent. But it hands back an
858
+ * actorPath the caller may then write to, so it must not be an arbitrary
859
+ * one: the search is the shared, path-sorted one, and when the label names
860
+ * several actors the result says so and lists them all instead of presenting
861
+ * one as though it were the only. */
279
862
  inline TSharedPtr<FJsonValue> MCPCheckActorLabelExists(
280
863
  UWorld* World,
281
864
  const FString& Label,
@@ -283,22 +866,37 @@ inline TSharedPtr<FJsonValue> MCPCheckActorLabelExists(
283
866
  const FString& FriendlyType = TEXT("Actor"))
284
867
  {
285
868
  if (!World || Label.IsEmpty()) return TSharedPtr<FJsonValue>();
286
- for (TActorIterator<AActor> It(World); It; ++It)
869
+ TArray<AActor*> Matches;
870
+ MCPCollectActorsByToken(World, Label, EMCPActorMatch::Label, Matches);
871
+ if (Matches.Num() == 0) return TSharedPtr<FJsonValue>();
872
+
873
+ if (OnConflict == TEXT("error"))
874
+ {
875
+ return MCPError(FString::Printf(TEXT("%s '%s' already exists"), *FriendlyType, *Label));
876
+ }
877
+
878
+ auto Existing = MCPSuccess();
879
+ MCPSetExisted(Existing);
880
+ Existing->SetStringField(TEXT("actorLabel"), Label);
881
+ Existing->SetStringField(TEXT("actorPath"), Matches[0]->GetPathName());
882
+ Existing->SetNumberField(TEXT("existingCount"), Matches.Num());
883
+ if (Matches.Num() > 1)
287
884
  {
288
- if (It->GetActorLabel() == Label)
885
+ // Deliberately NOT the 'ambiguous' key: that one marks a refusal, and
886
+ // this is a success. A consumer branching on 'ambiguous' must not read
887
+ // an idempotent no-op as a call that did nothing because it refused.
888
+ Existing->SetBoolField(TEXT("labelIsAmbiguous"), true);
889
+ TArray<TSharedPtr<FJsonValue>> Rows;
890
+ for (AActor* Match : Matches)
289
891
  {
290
- if (OnConflict == TEXT("error"))
291
- {
292
- return MCPError(FString::Printf(TEXT("%s '%s' already exists"), *FriendlyType, *Label));
293
- }
294
- auto Existing = MCPSuccess();
295
- MCPSetExisted(Existing);
296
- Existing->SetStringField(TEXT("actorLabel"), Label);
297
- Existing->SetStringField(TEXT("actorPath"), It->GetPathName());
298
- return MCPResult(Existing);
892
+ Rows.Add(MakeShared<FJsonValueObject>(MCPDescribeActorCandidate(Match)));
299
893
  }
894
+ Existing->SetArrayField(TEXT("candidates"), Rows);
895
+ Existing->SetStringField(TEXT("note"), FString::Printf(
896
+ TEXT("%d actors already carry the label '%s'. actorPath names the first by object path; address any of them with the actorPath from candidates."),
897
+ Matches.Num(), *Label));
300
898
  }
301
- return TSharedPtr<FJsonValue>();
899
+ return MCPResult(Existing);
302
900
  }
303
901
 
304
902
  /** Load a Blueprint by path and return its CDO cast to T. Returns nullptr
@@ -400,6 +998,101 @@ inline bool OptionalBool(
400
998
  return Params->TryGetBoolField(Key, Value) ? Value : DefaultValue;
401
999
  }
402
1000
 
1001
+ /**
1002
+ * Why an actor filter matched nothing, in terms of what it WOULD have matched.
1003
+ *
1004
+ * Level actions do not agree on filter semantics and their parameter names do
1005
+ * not warn you: get_outliner's nameFilter is a case-insensitive substring over
1006
+ * the label OR the internal name, while delete_actors' labelPrefix is a
1007
+ * case-sensitive prefix over the label only. The same string selects different
1008
+ * sets, and the losing call returns success with matched:0, which a caller
1009
+ * reasonably reads as "nothing to do".
1010
+ *
1011
+ * So a zero match reports the counts under the OTHER semantics rather than
1012
+ * leaving the caller to discover them. Returns an unset pointer when the
1013
+ * string would have matched nothing under any of them, because then a zero
1014
+ * really does mean zero.
1015
+ */
1016
+ inline TSharedPtr<FJsonObject> MCPDescribeZeroActorMatch(UWorld* World, const FString& Needle)
1017
+ {
1018
+ if (!World || Needle.IsEmpty())
1019
+ {
1020
+ return nullptr;
1021
+ }
1022
+
1023
+ int32 LabelContains = 0;
1024
+ int32 NameContains = 0;
1025
+ int32 PrefixIgnoringCase = 0;
1026
+ TArray<TSharedPtr<FJsonValue>> Samples;
1027
+
1028
+ for (TActorIterator<AActor> It(World); It; ++It)
1029
+ {
1030
+ AActor* Actor = *It;
1031
+ if (!Actor) continue;
1032
+ const FString Label = Actor->GetActorLabel();
1033
+ const FString Name = Actor->GetName();
1034
+ const bool bLabelContains = Label.Contains(Needle, ESearchCase::IgnoreCase);
1035
+ const bool bNameContains = Name.Contains(Needle, ESearchCase::IgnoreCase);
1036
+ const bool bPrefix = Label.StartsWith(Needle, ESearchCase::IgnoreCase);
1037
+ if (bLabelContains) ++LabelContains;
1038
+ if (bNameContains) ++NameContains;
1039
+ if (bPrefix) ++PrefixIgnoringCase;
1040
+ if ((bLabelContains || bNameContains) && Samples.Num() < 5)
1041
+ {
1042
+ Samples.Add(MakeShared<FJsonValueString>(
1043
+ FString::Printf(TEXT("%s (internal name %s)"), *Label, *Name)));
1044
+ }
1045
+ }
1046
+
1047
+ if (LabelContains == 0 && NameContains == 0 && PrefixIgnoringCase == 0)
1048
+ {
1049
+ return nullptr;
1050
+ }
1051
+
1052
+ TSharedPtr<FJsonObject> Hint = MakeShared<FJsonObject>();
1053
+ Hint->SetStringField(TEXT("filter"), Needle);
1054
+ Hint->SetNumberField(TEXT("actorsWhoseLabelContainsIt"), LabelContains);
1055
+ Hint->SetNumberField(TEXT("actorsWhoseInternalNameContainsIt"), NameContains);
1056
+ Hint->SetNumberField(TEXT("actorsWhoseLabelStartsWithItIgnoringCase"), PrefixIgnoringCase);
1057
+ Hint->SetArrayField(TEXT("samples"), Samples);
1058
+ Hint->SetStringField(TEXT("note"),
1059
+ TEXT("labelPrefix is a case-sensitive PREFIX over the EDITOR LABEL. level(get_outliner)'s nameFilter is a case-insensitive SUBSTRING over the label OR the internal name, so the same string selects a different set there. Use labelContains for a substring over the label, or nameContains for one over the internal name."));
1060
+ return Hint;
1061
+ }
1062
+
1063
+ /**
1064
+ * Note that an actor enumeration only saw the actors that are loaded.
1065
+ *
1066
+ * Every actor query in this plugin iterates the world, and on a World
1067
+ * Partition map that is a real answer but not the whole answer. Saying so
1068
+ * turns a silently wrong zero into an actionable one.
1069
+ */
1070
+ inline void MCPNoteLoadedOnlyEnumeration(UWorld* World, TSharedPtr<FJsonObject> Result)
1071
+ {
1072
+ if (!World || !Result.IsValid() || !World->IsPartitionedWorld())
1073
+ {
1074
+ return;
1075
+ }
1076
+ Result->SetBoolField(TEXT("partitionedWorld"), true);
1077
+ Result->SetStringField(TEXT("enumerationNote"),
1078
+ TEXT("This is a World Partition map and only LOADED actors were enumerated. An actor whose cell is not streamed in is invisible to every world query, including this one. Use level(list_actor_descs) to see the unloaded ones and level(load_actor_descs) to pin them first."));
1079
+ }
1080
+
1081
+ /** Render a TArray<FString> as a JSON string array. The inverse of
1082
+ * JsonArrayToStringList, shared so batch handlers that report label lists do
1083
+ * not each define their own file-local copy (unity build: two anonymous
1084
+ * namespaces sharing a blob merge, and the second definition is C2084). */
1085
+ inline TArray<TSharedPtr<FJsonValue>> MCPStringListToJson(const TArray<FString>& Values)
1086
+ {
1087
+ TArray<TSharedPtr<FJsonValue>> Out;
1088
+ Out.Reserve(Values.Num());
1089
+ for (const FString& Value : Values)
1090
+ {
1091
+ Out.Add(MakeShared<FJsonValueString>(Value));
1092
+ }
1093
+ return Out;
1094
+ }
1095
+
403
1096
  /** Extract a JSON array of strings into a TArray<FString>. */
404
1097
  inline TArray<FString> JsonArrayToStringList(const TArray<TSharedPtr<FJsonValue>>* Arr)
405
1098
  {
@@ -1026,46 +1719,284 @@ inline UWorld* ResolveWorldFromParams(const TSharedPtr<FJsonObject>& Params, con
1026
1719
  UWorld* WorldVar = GetEditorWorld(); \
1027
1720
  if (!WorldVar) return MCPError(TEXT("Editor world not available"));
1028
1721
 
1029
- /** Load an asset by path with fallback to ObjectPath format. Returns nullptr if not found. */
1722
+ /** Load an asset of a known type by path. Returns nullptr when the path names
1723
+ * nothing, and also when it names something of another type.
1724
+ *
1725
+ * #957/#913: this used to run its own two-step resolution, one step short of
1726
+ * the one asset(read) uses, so a short package path for an asset that was not
1727
+ * loaded yet answered nullptr here and resolved fine there. It now defers to
1728
+ * MCPLoadAssetObject so there is exactly one answer to "what does this path
1729
+ * name" in the whole plugin. */
1030
1730
  template <typename T>
1031
1731
  T* LoadAssetByPath(const FString& AssetPath)
1032
1732
  {
1033
- T* Asset = LoadObject<T>(nullptr, *AssetPath);
1034
- if (Asset) return Asset;
1733
+ return Cast<T>(MCPLoadAssetObject(AssetPath));
1734
+ }
1035
1735
 
1036
- // Try ObjectPath format: "/Game/Foo/Bar" → "/Game/Foo/Bar.Bar"
1037
- if (!AssetPath.Contains(TEXT(".")))
1736
+ /** The answer for a typed load that came back empty: names the class that was
1737
+ * found when the path resolved to the wrong thing, and falls through to the
1738
+ * full path diagnostic when it resolved to nothing. */
1739
+ inline TSharedPtr<FJsonValue> MCPAssetLoadError(const FString& AssetPath, const TCHAR* ExpectedType)
1740
+ {
1741
+ if (UObject* Found = MCPLoadAssetObject(AssetPath))
1038
1742
  {
1039
- FString AssetName;
1040
- AssetPath.Split(TEXT("/"), nullptr, &AssetName, ESearchCase::CaseSensitive, ESearchDir::FromEnd);
1041
- Asset = LoadObject<T>(nullptr, *(AssetPath + TEXT(".") + AssetName));
1743
+ return MCPAssetWrongTypeError(AssetPath, Found, ExpectedType);
1042
1744
  }
1043
- return Asset;
1745
+ return MCPAssetNotFoundError(AssetPath);
1044
1746
  }
1045
1747
 
1046
1748
  /** Load an asset or return an error response. Assigns to OutVar. */
1047
1749
  #define REQUIRE_ASSET(Type, OutVar, AssetPath) \
1048
1750
  Type* OutVar = LoadAssetByPath<Type>(AssetPath); \
1049
- if (!OutVar) return MCPError(FString::Printf(TEXT("%s not found: %s"), TEXT(#Type), *AssetPath));
1751
+ if (!OutVar) return MCPAssetLoadError(AssetPath, TEXT(#Type));
1752
+
1753
+ /** Export a property's value as text, honouring C-style fixed arrays.
1754
+ *
1755
+ * A UPROPERTY declared as `int32 Foo[3]` is ONE FProperty with ArrayDim == 3,
1756
+ * not three properties. ExportTextItem_Direct exports a single element, so a
1757
+ * caller that passes ContainerPtrToValuePtr<void>(Container) with no index
1758
+ * gets element 0 and nothing else, and the value reads as a plain scalar.
1759
+ *
1760
+ * That is how #927 hid two thirds of RecastNavMesh's NavMeshResolutionParams:
1761
+ * the Low tier was reported as if it were the whole property while the engine
1762
+ * was generating from Default and High, so a navmesh diagnosis was performed
1763
+ * against numbers the engine was not using.
1764
+ *
1765
+ * Returns a JSON string for a normal property, and a JSON array of one string
1766
+ * per element for a fixed array, so a caller can tell the two apart. */
1767
+ inline TSharedPtr<FJsonValue> MCPExportPropertyValue(const FProperty* Prop, const void* Container)
1768
+ {
1769
+ if (!Prop || !Container) return MakeShared<FJsonValueString>(FString());
1770
+
1771
+ auto ExportOne = [Prop, Container](int32 Index) -> FString
1772
+ {
1773
+ FString Text;
1774
+ Prop->ExportTextItem_Direct(
1775
+ Text, Prop->ContainerPtrToValuePtr<void>(Container, Index), nullptr, nullptr, PPF_None);
1776
+ return Text;
1777
+ };
1778
+
1779
+ if (Prop->ArrayDim <= 1)
1780
+ {
1781
+ return MakeShared<FJsonValueString>(ExportOne(0));
1782
+ }
1783
+
1784
+ TArray<TSharedPtr<FJsonValue>> Elements;
1785
+ Elements.Reserve(Prop->ArrayDim);
1786
+ for (int32 Index = 0; Index < Prop->ArrayDim; ++Index)
1787
+ {
1788
+ Elements.Add(MakeShared<FJsonValueString>(ExportOne(Index)));
1789
+ }
1790
+ return MakeShared<FJsonValueArray>(Elements);
1791
+ }
1792
+
1793
+ /** True when a property is a C-style fixed array, so callers that must emit a
1794
+ * scalar can say the value was truncated rather than silently truncating. */
1795
+ inline bool MCPPropertyIsFixedArray(const FProperty* Prop)
1796
+ {
1797
+ return Prop != nullptr && Prop->ArrayDim > 1;
1798
+ }
1050
1799
 
1051
1800
  // ── Package save ─────────────────────────────────────────────────────────────
1052
1801
 
1802
+ /** True when the package is a map package, i.e. one whose on-disk form is a
1803
+ * ".umap" rather than a ".uasset".
1804
+ *
1805
+ * #949: writing a world package with the asset extension does not fail. It
1806
+ * creates a second file that claims the same long package name, so the level
1807
+ * then exists twice on disk and the two copies diverge silently as different
1808
+ * save paths write different files. Unreal resolves the ".uasset" first, so
1809
+ * the stale fork is the one that wins.
1810
+ *
1811
+ * ContainsMap is the package flag Unreal sets on world packages and is the
1812
+ * same test editor(save_dirty) branches on. FindWorldInPackage is the backstop
1813
+ * for a world built in memory whose flag has not been stamped yet. One-file-
1814
+ * per-actor packages under __ExternalActors__ hold an AActor and no UWorld, so
1815
+ * both tests say false and they keep the ".uasset" extension OFPA expects. */
1816
+ inline bool IsMapPackage(UPackage* Package)
1817
+ {
1818
+ if (!Package) return false;
1819
+ return Package->ContainsMap() || UWorld::FindWorldInPackage(Package) != nullptr;
1820
+ }
1821
+
1822
+ /** On-disk file extension for a package, dot included. ".umap" for world
1823
+ * packages, ".uasset" for everything else. */
1824
+ inline const FString& PackageFileExtension(UPackage* Package)
1825
+ {
1826
+ return IsMapPackage(Package)
1827
+ ? FPackageName::GetMapPackageExtension()
1828
+ : FPackageName::GetAssetPackageExtension();
1829
+ }
1830
+
1831
+ /** Resolve the on-disk filename a package must be written to, extension
1832
+ * included. Returns false when the package name has no mounted root, which
1833
+ * keeps callers off FPackageName::LongPackageNameToFilename - that one is
1834
+ * fatal rather than recoverable when the name does not resolve. */
1835
+ inline bool ResolvePackageFileName(UPackage* Package, FString& OutFileName)
1836
+ {
1837
+ if (!Package) return false;
1838
+ return FPackageName::TryConvertLongPackageNameToFilename(
1839
+ Package->GetName(), OutFileName, PackageFileExtension(Package));
1840
+ }
1841
+
1842
+ /** Why an asset's package cannot be written, answered BEFORE the save is
1843
+ * attempted. Returns true when the write is blocked, with OutReason carrying
1844
+ * the sentence to hand the caller.
1845
+ *
1846
+ * #932: blueprint(reparent) saved as part of the operation, and a read-only
1847
+ * .uasset (a file never checked out of source control) turned the failed save
1848
+ * into a FATAL engine error that took the whole editor process down. The asset
1849
+ * was undamaged and the call replayed cleanly after a checkout, but no handler
1850
+ * may answer a routine, foreseeable condition with a crash.
1851
+ *
1852
+ * Asking the file system first is what turns that into an ordinary failure,
1853
+ * and it is the same order asset(set_property) already uses (#931). It lives
1854
+ * here, as one function, because "can this package be written" has to have a
1855
+ * single answer: the protected-mount guardrail had four copies once and two of
1856
+ * them enforced a weaker rule than the others. */
1857
+ inline bool MCPPackageWriteBlocked(UObject* Asset, FString& OutReason)
1858
+ {
1859
+ OutReason.Reset();
1860
+
1861
+ UPackage* Package = Asset ? Asset->GetOutermost() : nullptr;
1862
+ if (!Package)
1863
+ {
1864
+ OutReason = TEXT("The asset has no package, so there is nothing to write.");
1865
+ return true;
1866
+ }
1867
+
1868
+ const FString PackageName = Package->GetName();
1869
+ if (MCPIsProtectedAssetPath(PackageName))
1870
+ {
1871
+ OutReason = FString::Printf(
1872
+ TEXT("'%s' is on a protected mount, which the bridge never writes to."),
1873
+ *PackageName);
1874
+ return true;
1875
+ }
1876
+
1877
+ FString PackageFileName;
1878
+ if (!ResolvePackageFileName(Package, PackageFileName))
1879
+ {
1880
+ OutReason = FString::Printf(
1881
+ TEXT("'%s' has no mounted content root, so there is no file to write it to."),
1882
+ *PackageName);
1883
+ return true;
1884
+ }
1885
+
1886
+ // Only a file that already exists can be read-only. A package saved for the
1887
+ // first time has nothing on disk to check, and asking about a missing file
1888
+ // answers "not read-only", which is the right answer for a create.
1889
+ if (IFileManager::Get().FileExists(*PackageFileName)
1890
+ && IFileManager::Get().IsReadOnly(*PackageFileName))
1891
+ {
1892
+ OutReason = FString::Printf(
1893
+ TEXT("'%s' is read-only on disk. Check it out of source control or clear the read-only flag, then retry."),
1894
+ *PackageFileName);
1895
+ return true;
1896
+ }
1897
+
1898
+ return false;
1899
+ }
1900
+
1053
1901
  /** Mark the asset's package dirty and save it to disk. Used by every create/
1054
1902
  * mutate handler that wants changes persisted across editor restarts.
1055
- * No-op if Asset or its package is null. Returns true on successful save. */
1903
+ * No-op if Asset or its package is null. Returns true on successful save.
1904
+ *
1905
+ * Refuses before the engine is asked to write a file it cannot open (#932),
1906
+ * so the worst outcome of a read-only or protected package is a false return
1907
+ * rather than a fatal error. Callers that want the sentence explaining the
1908
+ * false use SaveAssetPackageChecked. */
1056
1909
  inline bool SaveAssetPackage(UObject* Asset)
1057
1910
  {
1058
1911
  if (!Asset) return false;
1059
1912
  UPackage* Package = Asset->GetOutermost();
1060
1913
  if (!Package) return false;
1061
1914
  Package->MarkPackageDirty();
1062
- const FString PackageFileName = FPackageName::LongPackageNameToFilename(
1063
- Package->GetName(), FPackageName::GetAssetPackageExtension());
1915
+
1916
+ FString BlockedReason;
1917
+ if (MCPPackageWriteBlocked(Asset, BlockedReason)) return false;
1918
+
1919
+ // The extension has to follow the package, not the call site. Any handler
1920
+ // that mutates an actor or component in the open level reaches this with a
1921
+ // world package as the outermost (#949).
1922
+ FString PackageFileName;
1923
+ if (!ResolvePackageFileName(Package, PackageFileName)) return false;
1064
1924
  FSavePackageArgs SaveArgs;
1065
1925
  SaveArgs.TopLevelFlags = RF_Standalone;
1066
1926
  return UPackage::SavePackage(Package, nullptr, *PackageFileName, SaveArgs);
1067
1927
  }
1068
1928
 
1929
+ /** SaveAssetPackage, with the reason when it did not write. A handler that
1930
+ * reports its own persistence uses this so a refusal reads as a named cause
1931
+ * rather than a bare false. */
1932
+ inline bool SaveAssetPackageChecked(UObject* Asset, FString& OutReason)
1933
+ {
1934
+ if (MCPPackageWriteBlocked(Asset, OutReason)) return false;
1935
+ if (SaveAssetPackage(Asset)) return true;
1936
+
1937
+ UPackage* Package = Asset ? Asset->GetOutermost() : nullptr;
1938
+ OutReason = FString::Printf(
1939
+ TEXT("The editor refused to write '%s'. The output log carries the reason."),
1940
+ Package ? *Package->GetName() : TEXT("(no package)"));
1941
+ return false;
1942
+ }
1943
+
1944
+ /** The refusal an action that saves as a side effect returns when the package
1945
+ * cannot be written. Returns nullptr when the write may go ahead, so a handler
1946
+ * reads as `if (auto Blocked = MCPAssetWriteBlockedError(...)) return Blocked;`
1947
+ * placed BEFORE the first mutation.
1948
+ *
1949
+ * Operation names what the caller asked for, in the imperative, so the message
1950
+ * reads "Cannot reparent this Blueprint: ...". */
1951
+ inline TSharedPtr<FJsonValue> MCPAssetWriteBlockedError(
1952
+ UObject* Asset,
1953
+ const FString& AssetPath,
1954
+ const TCHAR* Operation)
1955
+ {
1956
+ FString Reason;
1957
+ if (!MCPPackageWriteBlocked(Asset, Reason)) return nullptr;
1958
+
1959
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
1960
+ Obj->SetBoolField(TEXT("success"), false);
1961
+ Obj->SetStringField(TEXT("error"), FString::Printf(
1962
+ TEXT("Cannot %s: %s Nothing was changed."), Operation, *Reason));
1963
+ Obj->SetStringField(TEXT("assetPath"), AssetPath);
1964
+ Obj->SetStringField(TEXT("path"), AssetPath);
1965
+ Obj->SetStringField(TEXT("reason"), TEXT("package_not_writable"));
1966
+ Obj->SetBoolField(TEXT("saved"), false);
1967
+ if (UPackage* Package = Asset ? Asset->GetOutermost() : nullptr)
1968
+ {
1969
+ Obj->SetStringField(TEXT("packageName"), Package->GetName());
1970
+ FString PackageFileName;
1971
+ if (ResolvePackageFileName(Package, PackageFileName))
1972
+ {
1973
+ Obj->SetStringField(TEXT("packageFile"), PackageFileName);
1974
+ }
1975
+ }
1976
+ return MakeShared<FJsonValueObject>(Obj);
1977
+ }
1978
+
1979
+ /** Record on a result whether the side-effect save reached disk, and why not
1980
+ * when it did not. A save that did not happen is a failure, not a success with
1981
+ * a footnote: the caller's next read comes off the in-memory object and looks
1982
+ * correct right up until the editor restarts (#931). */
1983
+ inline void MCPNoteSaveOutcome(
1984
+ const TSharedPtr<FJsonObject>& Result,
1985
+ const FString& AssetPath,
1986
+ bool bSaved,
1987
+ const FString& Reason)
1988
+ {
1989
+ if (!Result.IsValid()) return;
1990
+ Result->SetBoolField(TEXT("saved"), bSaved);
1991
+ if (bSaved) return;
1992
+
1993
+ Result->SetBoolField(TEXT("success"), false);
1994
+ Result->SetStringField(TEXT("saveError"), Reason);
1995
+ Result->SetStringField(TEXT("error"), FString::Printf(
1996
+ TEXT("The change was applied in memory but '%s' was not written: %s"),
1997
+ *AssetPath, *Reason));
1998
+ }
1999
+
1069
2000
  // ── GC root RAII ─────────────────────────────────────────────────────────────
1070
2001
 
1071
2002
  /** RAII: root a UObject on construction, unroot on scope exit. Prevents the