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.
- package/README.md +4 -3
- package/dist/action-class.js +56 -3
- package/dist/action-class.js.map +1 -1
- package/dist/bridge-timeouts.d.ts +64 -0
- package/dist/bridge-timeouts.js +118 -0
- package/dist/bridge-timeouts.js.map +1 -0
- package/dist/bridge.js +10 -2
- package/dist/bridge.js.map +1 -1
- package/dist/editor-control.d.ts +72 -5
- package/dist/editor-control.js +164 -140
- package/dist/editor-control.js.map +1 -1
- package/dist/editor-target.d.ts +55 -1
- package/dist/editor-target.js +107 -10
- package/dist/editor-target.js.map +1 -1
- package/dist/engine-observer.d.ts +59 -5
- package/dist/engine-observer.js +249 -29
- package/dist/engine-observer.js.map +1 -1
- package/dist/engine-root.d.ts +91 -0
- package/dist/engine-root.js +448 -0
- package/dist/engine-root.js.map +1 -0
- package/dist/epic-enrich.js +16 -1
- package/dist/epic-enrich.js.map +1 -1
- package/dist/feedback-cli.js +4 -0
- package/dist/feedback-cli.js.map +1 -1
- package/dist/feedback-deferred.d.ts +9 -0
- package/dist/feedback-deferred.js +2 -0
- package/dist/feedback-deferred.js.map +1 -1
- package/dist/feedback-fallback.d.ts +99 -0
- package/dist/feedback-fallback.js +173 -0
- package/dist/feedback-fallback.js.map +1 -0
- package/dist/flow/write-methods.js +20 -0
- package/dist/flow/write-methods.js.map +1 -1
- package/dist/lean-context.js +8 -3
- package/dist/lean-context.js.map +1 -1
- package/dist/locking.js +11 -2
- package/dist/locking.js.map +1 -1
- package/dist/project.d.ts +11 -0
- package/dist/project.js +23 -2
- package/dist/project.js.map +1 -1
- package/dist/python-gate.d.ts +95 -0
- package/dist/python-gate.js +175 -0
- package/dist/python-gate.js.map +1 -0
- package/dist/tool-counts.json +16 -16
- package/dist/tools/animation.js +288 -24
- package/dist/tools/animation.js.map +1 -1
- package/dist/tools/asset.js +79 -14
- package/dist/tools/asset.js.map +1 -1
- package/dist/tools/audio.js +59 -5
- package/dist/tools/audio.js.map +1 -1
- package/dist/tools/blueprint.js +29 -12
- package/dist/tools/blueprint.js.map +1 -1
- package/dist/tools/chooser.js +4 -1
- package/dist/tools/chooser.js.map +1 -1
- package/dist/tools/editor.js +81 -43
- package/dist/tools/editor.js.map +1 -1
- package/dist/tools/feedback.js +214 -48
- package/dist/tools/feedback.js.map +1 -1
- package/dist/tools/foliage.js +27 -1
- package/dist/tools/foliage.js.map +1 -1
- package/dist/tools/gameplay.js +38 -11
- package/dist/tools/gameplay.js.map +1 -1
- package/dist/tools/gas.js +17 -5
- package/dist/tools/gas.js.map +1 -1
- package/dist/tools/landscape.js +22 -7
- package/dist/tools/landscape.js.map +1 -1
- package/dist/tools/level.js +322 -51
- package/dist/tools/level.js.map +1 -1
- package/dist/tools/material.js +10 -4
- package/dist/tools/material.js.map +1 -1
- package/dist/tools/niagara.js +3 -2
- package/dist/tools/niagara.js.map +1 -1
- package/dist/tools/pcg.js +6 -5
- package/dist/tools/pcg.js.map +1 -1
- package/dist/tools/project.js +47 -20
- package/dist/tools/project.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.js +46 -3
- package/dist/types.js.map +1 -1
- package/dist/ue-mcp.default.yml +493 -125
- package/package.json +2 -2
- package/plugin/ue_mcp_bridge/NATIVE_RUNTIME_VISIBILITY.md +53 -0
- package/plugin/ue_mcp_bridge/NATIVE_STATIC_MESH_USAGE_PROGRESS.md +115 -0
- package/plugin/ue_mcp_bridge/README_MassHandlers.md +97 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/BridgeServer.cpp +14 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/GameThreadExecutor.cpp +21 -4
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/GameThreadExecutor.h +9 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/HandlerJsonProperty.h +395 -69
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp +79 -3
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.h +25 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRig.cpp +3 -3
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp +3061 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp +961 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp +900 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Pose.cpp +769 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Sequence.cpp +44 -3
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_SkeletalLive.cpp +217 -11
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp +551 -24
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp +735 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.cpp +352 -73
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.h +8 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkProperties.cpp +1 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkRead.cpp +503 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkRead.h +21 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkUpsert.cpp +2 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Enum.cpp +2 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Geometry.cpp +902 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Geometry.h +33 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Import.cpp +478 -134
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Mesh.cpp +5 -5
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_MeshBoolean.cpp +933 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_MeshBoolean.h +35 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Redirectors.cpp +478 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_RenderTarget.cpp +1 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Sockets.cpp +4 -4
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Struct.cpp +2 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Subobject.cpp +319 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AudioHandlers_SoundCue.cpp +3 -3
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers.cpp +482 -35
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers.h +10 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Collision.cpp +502 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Collision.h +43 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Functions.cpp +89 -7
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Graph.cpp +17 -51
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Internal.h +107 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Properties.cpp +10 -10
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Search.cpp +648 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DemoHandlers.cpp +5 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.cpp +252 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.h +10 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp +252 -41
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.h +4 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_Build.cpp +250 -27
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_PIE.cpp +303 -125
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_PIERuntime.cpp +73 -50
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_RuntimeVisibility.cpp +757 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers.cpp +11 -2
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers.h +3 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers_Batch.cpp +419 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers.cpp +39 -161
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers.h +66 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers_BTAuthoring.cpp +1323 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers_BehaviorTree.cpp +1211 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers.cpp +2 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers.h +60 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers_Runtime.cpp +363 -12
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/HandlerEditorState.h +84 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.cpp +1288 -36
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.h +3 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers_Sculpt.cpp +41 -16
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.cpp +959 -213
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.h +66 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_BatchWrite.cpp +1571 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Convert.cpp +316 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Inspection.cpp +280 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_InstanceProjection.cpp +666 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_InstanceProjection_Internal.h +63 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Lights.cpp +43 -12
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_MultiLevel.cpp +13 -47
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_PostProcess.cpp +527 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Query.cpp +1169 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Refresh.cpp +723 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Save.cpp +325 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Spatial.cpp +360 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Trace.cpp +178 -58
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Transient.cpp +337 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Volumes.cpp +25 -12
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_WorldPartition.cpp +10 -9
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_WorldPartitionSettings.cpp +686 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MassHandlers.cpp +467 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MassHandlers.h +22 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers.cpp +452 -175
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers.h +5 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers_Build.cpp +597 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/NiagaraHandlers.cpp +17 -15
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PCGHandlers.cpp +38 -168
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PhysicsHandlers.cpp +39 -30
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers.cpp +392 -78
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers.h +2 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers_Internal.h +83 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SkeletalMeshHandlers.cpp +200 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SkeletalMeshHandlers.h +15 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SplineHandlers.cpp +19 -12
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/StateTreeHandlers.cpp +31 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.cpp +307 -69
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.h +84 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers_Extraction.cpp +8 -41
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers_Properties.cpp +33 -52
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/ActorSelectorTests.cpp +344 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationControlRigTimelineTests.cpp +60 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationPoseTests.cpp +115 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetBulkReadTests.cpp +177 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetDeleteGuardTests.cpp +276 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetPathResolutionTests.cpp +243 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetPropertyPersistTests.cpp +213 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetSubobjectTests.cpp +235 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AutomationRunnerTests.cpp +72 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BehaviorTreeAuthoringTests.cpp +526 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BehaviorTreeNodeTests.cpp +334 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BlueprintHandlerSurfaceTests.cpp +362 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BridgeProtocolTests.cpp +14 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/CollisionQueryTests.cpp +288 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/DataTableRowWriteTests.cpp +453 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/EditorFunctionCallTests.cpp +223 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/FoliageBatchTests.cpp +147 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/GameThreadGateTests.cpp +82 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/GasLiveAttributeTests.cpp +183 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LandscapeSampleTests.cpp +119 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelBatchWriteTests.cpp +176 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelComponentTreeInstanceCountTests.cpp +189 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelConvertBrushesTests.cpp +100 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelInstanceProjectionTests.cpp +265 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelPostProcessOverrideTests.cpp +258 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelQueryComponentsTests.cpp +195 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelRefreshTests.cpp +142 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelSaveReportTests.cpp +125 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelScriptHandlersTests.cpp +42 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelStaticMeshUsageTests.cpp +412 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelTraceWorldScopeTests.cpp +133 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelTransientActorTests.cpp +205 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelWorldPartitionSettingsTests.cpp +168 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MCPInstancedStructPathTestTypes.h +51 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MCPInstancedStructPathTests.cpp +379 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MaterialBuildTests.cpp +238 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MeshBooleanTests.cpp +170 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MeshGeometryTests.cpp +116 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/PackageSaveExtensionTests.cpp +161 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/PackageWriteGuardTests.cpp +253 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/RuntimeVisibilityTests.cpp +143 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerHandlerTests.cpp +660 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerScrubTests.cpp +50 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SkeletonDiffTests.cpp +685 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/WidgetBlueprintResolveTests.cpp +145 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/UE_MCP_Bridge.cpp +80 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerFunctionCall.h +509 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerQuery.h +407 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h +1008 -77
- package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs +87 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Private/MCPEngineStatus.cpp +152 -14
- package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Private/Tests/MCPEngineStatusTests.cpp +65 -0
- package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Public/MCPEngineStatus.h +32 -1
- package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/UE_MCP_BridgeStatus.Build.cs +2 -0
- package/skills/ue-mcp-animation/SKILL.md +154 -0
- 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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
521
|
+
return CaseInsensitive;
|
|
187
522
|
}
|
|
188
523
|
|
|
189
|
-
/**
|
|
190
|
-
*
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
202
|
-
*
|
|
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 (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
if (bHasLabel && It->GetActorLabel() == Label) return *It;
|
|
612
|
+
Rows.Add(MakeShared<FJsonValueObject>(MCPDescribeActorCandidate(Candidates[Index])));
|
|
213
613
|
}
|
|
214
|
-
|
|
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
|
-
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
|
|
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 (!
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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*
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1034
|
-
|
|
1733
|
+
return Cast<T>(MCPLoadAssetObject(AssetPath));
|
|
1734
|
+
}
|
|
1035
1735
|
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1063
|
-
|
|
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
|