unreal-engine-mcp-server 0.4.7 → 0.5.1
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/.env.example +26 -0
- package/.env.production +38 -7
- package/.eslintrc.json +0 -54
- package/.eslintrc.override.json +8 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +94 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
- package/.github/copilot-instructions.md +478 -45
- package/.github/dependabot.yml +19 -0
- package/.github/labeler.yml +24 -0
- package/.github/labels.yml +70 -0
- package/.github/pull_request_template.md +42 -0
- package/.github/release-drafter-config.yml +51 -0
- package/.github/workflows/auto-merge.yml +38 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/dependency-review.yml +17 -0
- package/.github/workflows/gemini-issue-triage.yml +172 -0
- package/.github/workflows/greetings.yml +27 -0
- package/.github/workflows/labeler.yml +17 -0
- package/.github/workflows/links.yml +80 -0
- package/.github/workflows/pr-size-labeler.yml +137 -0
- package/.github/workflows/publish-mcp.yml +13 -7
- package/.github/workflows/release-drafter.yml +23 -0
- package/.github/workflows/release.yml +112 -0
- package/.github/workflows/semantic-pull-request.yml +35 -0
- package/.github/workflows/smoke-test.yml +36 -0
- package/.github/workflows/stale.yml +28 -0
- package/CHANGELOG.md +338 -31
- package/CONTRIBUTING.md +140 -0
- package/GEMINI.md +115 -0
- package/Public/Plugin_setup_guide.mp4 +0 -0
- package/README.md +189 -128
- package/claude_desktop_config_example.json +7 -6
- package/dist/automation/bridge.d.ts +50 -0
- package/dist/automation/bridge.js +452 -0
- package/dist/automation/connection-manager.d.ts +23 -0
- package/dist/automation/connection-manager.js +107 -0
- package/dist/automation/handshake.d.ts +11 -0
- package/dist/automation/handshake.js +89 -0
- package/dist/automation/index.d.ts +3 -0
- package/dist/automation/index.js +3 -0
- package/dist/automation/message-handler.d.ts +12 -0
- package/dist/automation/message-handler.js +149 -0
- package/dist/automation/request-tracker.d.ts +25 -0
- package/dist/automation/request-tracker.js +98 -0
- package/dist/automation/types.d.ts +130 -0
- package/dist/automation/types.js +2 -0
- package/dist/cli.js +32 -5
- package/dist/config.d.ts +26 -0
- package/dist/config.js +59 -0
- package/dist/constants.d.ts +16 -0
- package/dist/constants.js +16 -0
- package/dist/graphql/loaders.d.ts +64 -0
- package/dist/graphql/loaders.js +117 -0
- package/dist/graphql/resolvers.d.ts +268 -0
- package/dist/graphql/resolvers.js +746 -0
- package/dist/graphql/schema.d.ts +5 -0
- package/dist/graphql/schema.js +437 -0
- package/dist/graphql/server.d.ts +26 -0
- package/dist/graphql/server.js +117 -0
- package/dist/graphql/types.d.ts +9 -0
- package/dist/graphql/types.js +2 -0
- package/dist/handlers/resource-handlers.d.ts +20 -0
- package/dist/handlers/resource-handlers.js +180 -0
- package/dist/index.d.ts +33 -18
- package/dist/index.js +130 -619
- package/dist/resources/actors.d.ts +17 -12
- package/dist/resources/actors.js +56 -76
- package/dist/resources/assets.d.ts +6 -14
- package/dist/resources/assets.js +115 -147
- package/dist/resources/levels.d.ts +13 -13
- package/dist/resources/levels.js +25 -34
- package/dist/server/resource-registry.d.ts +20 -0
- package/dist/server/resource-registry.js +37 -0
- package/dist/server/tool-registry.d.ts +23 -0
- package/dist/server/tool-registry.js +322 -0
- package/dist/server-setup.d.ts +20 -0
- package/dist/server-setup.js +71 -0
- package/dist/services/health-monitor.d.ts +34 -0
- package/dist/services/health-monitor.js +105 -0
- package/dist/services/metrics-server.d.ts +11 -0
- package/dist/services/metrics-server.js +105 -0
- package/dist/tools/actors.d.ts +163 -9
- package/dist/tools/actors.js +356 -311
- package/dist/tools/animation.d.ts +135 -4
- package/dist/tools/animation.js +510 -411
- package/dist/tools/assets.d.ts +75 -29
- package/dist/tools/assets.js +265 -284
- package/dist/tools/audio.d.ts +102 -42
- package/dist/tools/audio.js +272 -685
- package/dist/tools/base-tool.d.ts +17 -0
- package/dist/tools/base-tool.js +46 -0
- package/dist/tools/behavior-tree.d.ts +94 -0
- package/dist/tools/behavior-tree.js +39 -0
- package/dist/tools/blueprint.d.ts +208 -126
- package/dist/tools/blueprint.js +685 -832
- package/dist/tools/consolidated-tool-definitions.d.ts +5462 -1781
- package/dist/tools/consolidated-tool-definitions.js +829 -496
- package/dist/tools/consolidated-tool-handlers.d.ts +2 -1
- package/dist/tools/consolidated-tool-handlers.js +198 -1027
- package/dist/tools/debug.d.ts +143 -85
- package/dist/tools/debug.js +234 -180
- package/dist/tools/dynamic-handler-registry.d.ts +13 -0
- package/dist/tools/dynamic-handler-registry.js +23 -0
- package/dist/tools/editor.d.ts +30 -83
- package/dist/tools/editor.js +247 -244
- package/dist/tools/engine.d.ts +10 -4
- package/dist/tools/engine.js +13 -5
- package/dist/tools/environment.d.ts +30 -0
- package/dist/tools/environment.js +267 -0
- package/dist/tools/foliage.d.ts +65 -99
- package/dist/tools/foliage.js +221 -331
- package/dist/tools/handlers/actor-handlers.d.ts +3 -0
- package/dist/tools/handlers/actor-handlers.js +227 -0
- package/dist/tools/handlers/animation-handlers.d.ts +3 -0
- package/dist/tools/handlers/animation-handlers.js +185 -0
- package/dist/tools/handlers/argument-helper.d.ts +16 -0
- package/dist/tools/handlers/argument-helper.js +80 -0
- package/dist/tools/handlers/asset-handlers.d.ts +3 -0
- package/dist/tools/handlers/asset-handlers.js +496 -0
- package/dist/tools/handlers/audio-handlers.d.ts +3 -0
- package/dist/tools/handlers/audio-handlers.js +166 -0
- package/dist/tools/handlers/blueprint-handlers.d.ts +4 -0
- package/dist/tools/handlers/blueprint-handlers.js +358 -0
- package/dist/tools/handlers/common-handlers.d.ts +14 -0
- package/dist/tools/handlers/common-handlers.js +56 -0
- package/dist/tools/handlers/editor-handlers.d.ts +3 -0
- package/dist/tools/handlers/editor-handlers.js +119 -0
- package/dist/tools/handlers/effect-handlers.d.ts +3 -0
- package/dist/tools/handlers/effect-handlers.js +171 -0
- package/dist/tools/handlers/environment-handlers.d.ts +3 -0
- package/dist/tools/handlers/environment-handlers.js +170 -0
- package/dist/tools/handlers/graph-handlers.d.ts +3 -0
- package/dist/tools/handlers/graph-handlers.js +90 -0
- package/dist/tools/handlers/input-handlers.d.ts +3 -0
- package/dist/tools/handlers/input-handlers.js +21 -0
- package/dist/tools/handlers/inspect-handlers.d.ts +3 -0
- package/dist/tools/handlers/inspect-handlers.js +383 -0
- package/dist/tools/handlers/level-handlers.d.ts +3 -0
- package/dist/tools/handlers/level-handlers.js +237 -0
- package/dist/tools/handlers/lighting-handlers.d.ts +3 -0
- package/dist/tools/handlers/lighting-handlers.js +144 -0
- package/dist/tools/handlers/performance-handlers.d.ts +3 -0
- package/dist/tools/handlers/performance-handlers.js +130 -0
- package/dist/tools/handlers/pipeline-handlers.d.ts +3 -0
- package/dist/tools/handlers/pipeline-handlers.js +110 -0
- package/dist/tools/handlers/sequence-handlers.d.ts +3 -0
- package/dist/tools/handlers/sequence-handlers.js +376 -0
- package/dist/tools/handlers/system-handlers.d.ts +4 -0
- package/dist/tools/handlers/system-handlers.js +506 -0
- package/dist/tools/input.d.ts +19 -0
- package/dist/tools/input.js +89 -0
- package/dist/tools/introspection.d.ts +103 -40
- package/dist/tools/introspection.js +425 -568
- package/dist/tools/landscape.d.ts +54 -93
- package/dist/tools/landscape.js +284 -409
- package/dist/tools/level.d.ts +66 -27
- package/dist/tools/level.js +647 -675
- package/dist/tools/lighting.d.ts +77 -38
- package/dist/tools/lighting.js +445 -943
- package/dist/tools/logs.d.ts +3 -3
- package/dist/tools/logs.js +5 -57
- package/dist/tools/materials.d.ts +91 -24
- package/dist/tools/materials.js +194 -118
- package/dist/tools/niagara.d.ts +149 -39
- package/dist/tools/niagara.js +267 -182
- package/dist/tools/performance.d.ts +27 -13
- package/dist/tools/performance.js +203 -122
- package/dist/tools/physics.d.ts +32 -77
- package/dist/tools/physics.js +175 -582
- package/dist/tools/property-dictionary.d.ts +13 -0
- package/dist/tools/property-dictionary.js +82 -0
- package/dist/tools/sequence.d.ts +85 -60
- package/dist/tools/sequence.js +208 -747
- package/dist/tools/tool-definition-utils.d.ts +59 -0
- package/dist/tools/tool-definition-utils.js +35 -0
- package/dist/tools/ui.d.ts +64 -34
- package/dist/tools/ui.js +134 -214
- package/dist/types/automation-responses.d.ts +115 -0
- package/dist/types/automation-responses.js +2 -0
- package/dist/types/env.d.ts +0 -3
- package/dist/types/env.js +0 -7
- package/dist/types/responses.d.ts +249 -0
- package/dist/types/responses.js +2 -0
- package/dist/types/tool-interfaces.d.ts +898 -0
- package/dist/types/tool-interfaces.js +2 -0
- package/dist/types/tool-types.d.ts +183 -19
- package/dist/types/tool-types.js +0 -4
- package/dist/unreal-bridge.d.ts +24 -131
- package/dist/unreal-bridge.js +364 -1506
- package/dist/utils/command-validator.d.ts +9 -0
- package/dist/utils/command-validator.js +68 -0
- package/dist/utils/elicitation.d.ts +1 -1
- package/dist/utils/elicitation.js +12 -15
- package/dist/utils/error-handler.d.ts +2 -51
- package/dist/utils/error-handler.js +11 -87
- package/dist/utils/ini-reader.d.ts +3 -0
- package/dist/utils/ini-reader.js +69 -0
- package/dist/utils/logger.js +9 -6
- package/dist/utils/normalize.d.ts +3 -0
- package/dist/utils/normalize.js +56 -0
- package/dist/utils/path-security.d.ts +2 -0
- package/dist/utils/path-security.js +24 -0
- package/dist/utils/response-factory.d.ts +7 -0
- package/dist/utils/response-factory.js +27 -0
- package/dist/utils/response-validator.d.ts +3 -24
- package/dist/utils/response-validator.js +130 -81
- package/dist/utils/result-helpers.d.ts +4 -5
- package/dist/utils/result-helpers.js +15 -16
- package/dist/utils/safe-json.js +5 -11
- package/dist/utils/unreal-command-queue.d.ts +24 -0
- package/dist/utils/unreal-command-queue.js +120 -0
- package/dist/utils/validation.d.ts +0 -40
- package/dist/utils/validation.js +1 -78
- package/dist/wasm/index.d.ts +70 -0
- package/dist/wasm/index.js +535 -0
- package/docs/GraphQL-API.md +888 -0
- package/docs/Migration-Guide-v0.5.0.md +684 -0
- package/docs/Roadmap.md +53 -0
- package/docs/WebAssembly-Integration.md +628 -0
- package/docs/editor-plugin-extension.md +370 -0
- package/docs/handler-mapping.md +242 -0
- package/docs/native-automation-progress.md +128 -0
- package/docs/testing-guide.md +423 -0
- package/mcp-config-example.json +6 -6
- package/package.json +67 -28
- package/plugins/McpAutomationBridge/Config/FilterPlugin.ini +8 -0
- package/plugins/McpAutomationBridge/McpAutomationBridge.uplugin +64 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/McpAutomationBridge.Build.cs +189 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridgeGlobals.cpp +22 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridgeGlobals.h +30 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridgeHelpers.h +1983 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridgeModule.cpp +72 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridgeSettings.cpp +46 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridgeSubsystem.cpp +581 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_AnimationHandlers.cpp +2394 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_AssetQueryHandlers.cpp +300 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_AssetWorkflowHandlers.cpp +2807 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_AudioHandlers.cpp +1087 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BehaviorTreeHandlers.cpp +488 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BlueprintCreationHandlers.cpp +643 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BlueprintCreationHandlers.h +31 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BlueprintGraphHandlers.cpp +1184 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BlueprintHandlers.cpp +5652 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_BlueprintHandlers_List.cpp +152 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_ControlHandlers.cpp +2614 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_DebugHandlers.cpp +42 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_EditorFunctionHandlers.cpp +1237 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_EffectHandlers.cpp +1701 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_EnvironmentHandlers.cpp +2145 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_FoliageHandlers.cpp +954 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_InputHandlers.cpp +209 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_InsightsHandlers.cpp +41 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_LandscapeHandlers.cpp +1164 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_LevelHandlers.cpp +762 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_LightingHandlers.cpp +634 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_LogHandlers.cpp +136 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_MaterialGraphHandlers.cpp +494 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_NiagaraGraphHandlers.cpp +278 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_NiagaraHandlers.cpp +625 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_PerformanceHandlers.cpp +401 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_PipelineHandlers.cpp +67 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_ProcessRequest.cpp +735 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_PropertyHandlers.cpp +2634 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_RenderHandlers.cpp +189 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_SCSHandlers.cpp +917 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_SCSHandlers.h +39 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_SequenceHandlers.cpp +2670 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_SequencerHandlers.cpp +519 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_TestHandlers.cpp +38 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_UiHandlers.cpp +668 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpAutomationBridge_WorldPartitionHandlers.cpp +346 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpBridgeWebSocket.cpp +1330 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpBridgeWebSocket.h +149 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Private/McpConnectionManager.cpp +783 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Public/McpAutomationBridgeSettings.h +115 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Public/McpAutomationBridgeSubsystem.h +796 -0
- package/plugins/McpAutomationBridge/Source/McpAutomationBridge/Public/McpConnectionManager.h +117 -0
- package/scripts/check-unreal-connection.mjs +19 -0
- package/scripts/clean-tmp.js +23 -0
- package/scripts/patch-wasm.js +26 -0
- package/scripts/run-all-tests.mjs +136 -0
- package/scripts/smoke-test.ts +94 -0
- package/scripts/sync-mcp-plugin.js +143 -0
- package/scripts/test-no-plugin-alternates.mjs +113 -0
- package/scripts/validate-server.js +46 -0
- package/scripts/verify-automation-bridge.js +200 -0
- package/server.json +58 -21
- package/src/automation/bridge.ts +558 -0
- package/src/automation/connection-manager.ts +130 -0
- package/src/automation/handshake.ts +99 -0
- package/src/automation/index.ts +2 -0
- package/src/automation/message-handler.ts +167 -0
- package/src/automation/request-tracker.ts +123 -0
- package/src/automation/types.ts +107 -0
- package/src/cli.ts +33 -6
- package/src/config.ts +73 -0
- package/src/constants.ts +19 -0
- package/src/graphql/loaders.ts +244 -0
- package/src/graphql/resolvers.ts +1008 -0
- package/src/graphql/schema.ts +452 -0
- package/src/graphql/server.ts +156 -0
- package/src/graphql/types.ts +10 -0
- package/src/handlers/resource-handlers.ts +186 -0
- package/src/index.ts +166 -664
- package/src/resources/actors.ts +58 -76
- package/src/resources/assets.ts +148 -134
- package/src/resources/levels.ts +28 -33
- package/src/server/resource-registry.ts +47 -0
- package/src/server/tool-registry.ts +354 -0
- package/src/server-setup.ts +114 -0
- package/src/services/health-monitor.ts +132 -0
- package/src/services/metrics-server.ts +142 -0
- package/src/tools/actors.ts +426 -323
- package/src/tools/animation.ts +672 -461
- package/src/tools/assets.ts +364 -289
- package/src/tools/audio.ts +323 -766
- package/src/tools/base-tool.ts +52 -0
- package/src/tools/behavior-tree.ts +45 -0
- package/src/tools/blueprint.ts +792 -970
- package/src/tools/consolidated-tool-definitions.ts +993 -515
- package/src/tools/consolidated-tool-handlers.ts +258 -1146
- package/src/tools/debug.ts +292 -187
- package/src/tools/dynamic-handler-registry.ts +33 -0
- package/src/tools/editor.ts +329 -253
- package/src/tools/engine.ts +14 -3
- package/src/tools/environment.ts +281 -0
- package/src/tools/foliage.ts +330 -392
- package/src/tools/handlers/actor-handlers.ts +265 -0
- package/src/tools/handlers/animation-handlers.ts +237 -0
- package/src/tools/handlers/argument-helper.ts +142 -0
- package/src/tools/handlers/asset-handlers.ts +532 -0
- package/src/tools/handlers/audio-handlers.ts +194 -0
- package/src/tools/handlers/blueprint-handlers.ts +380 -0
- package/src/tools/handlers/common-handlers.ts +87 -0
- package/src/tools/handlers/editor-handlers.ts +123 -0
- package/src/tools/handlers/effect-handlers.ts +220 -0
- package/src/tools/handlers/environment-handlers.ts +183 -0
- package/src/tools/handlers/graph-handlers.ts +116 -0
- package/src/tools/handlers/input-handlers.ts +28 -0
- package/src/tools/handlers/inspect-handlers.ts +450 -0
- package/src/tools/handlers/level-handlers.ts +252 -0
- package/src/tools/handlers/lighting-handlers.ts +147 -0
- package/src/tools/handlers/performance-handlers.ts +132 -0
- package/src/tools/handlers/pipeline-handlers.ts +127 -0
- package/src/tools/handlers/sequence-handlers.ts +415 -0
- package/src/tools/handlers/system-handlers.ts +564 -0
- package/src/tools/input.ts +101 -0
- package/src/tools/introspection.ts +493 -584
- package/src/tools/landscape.ts +418 -507
- package/src/tools/level.ts +786 -708
- package/src/tools/lighting.ts +588 -984
- package/src/tools/logs.ts +9 -57
- package/src/tools/materials.ts +237 -121
- package/src/tools/niagara.ts +335 -168
- package/src/tools/performance.ts +320 -169
- package/src/tools/physics.ts +274 -613
- package/src/tools/property-dictionary.ts +98 -0
- package/src/tools/sequence.ts +276 -820
- package/src/tools/tool-definition-utils.ts +35 -0
- package/src/tools/ui.ts +205 -283
- package/src/types/automation-responses.ts +119 -0
- package/src/types/env.ts +0 -10
- package/src/types/responses.ts +355 -0
- package/src/types/tool-interfaces.ts +250 -0
- package/src/types/tool-types.ts +243 -21
- package/src/unreal-bridge.ts +460 -1550
- package/src/utils/command-validator.ts +76 -0
- package/src/utils/elicitation.ts +10 -7
- package/src/utils/error-handler.ts +14 -90
- package/src/utils/ini-reader.ts +86 -0
- package/src/utils/logger.ts +8 -3
- package/src/utils/normalize.test.ts +162 -0
- package/src/utils/normalize.ts +60 -0
- package/src/utils/path-security.ts +43 -0
- package/src/utils/response-factory.ts +44 -0
- package/src/utils/response-validator.ts +176 -56
- package/src/utils/result-helpers.ts +21 -19
- package/src/utils/safe-json.test.ts +90 -0
- package/src/utils/safe-json.ts +14 -11
- package/src/utils/unreal-command-queue.ts +152 -0
- package/src/utils/validation.test.ts +184 -0
- package/src/utils/validation.ts +4 -1
- package/src/wasm/index.ts +838 -0
- package/test-server.mjs +100 -0
- package/tests/run-unreal-tool-tests.mjs +242 -14
- package/tests/test-animation.mjs +369 -0
- package/tests/test-asset-advanced.mjs +82 -0
- package/tests/test-asset-errors.mjs +35 -0
- package/tests/test-asset-graph.mjs +311 -0
- package/tests/test-audio.mjs +417 -0
- package/tests/test-automation-timeouts.mjs +98 -0
- package/tests/test-behavior-tree.mjs +444 -0
- package/tests/test-blueprint-graph.mjs +410 -0
- package/tests/test-blueprint.mjs +577 -0
- package/tests/test-client-mode.mjs +86 -0
- package/tests/test-console-command.mjs +56 -0
- package/tests/test-control-actor.mjs +425 -0
- package/tests/test-control-editor.mjs +112 -0
- package/tests/test-graphql.mjs +372 -0
- package/tests/test-input.mjs +349 -0
- package/tests/test-inspect.mjs +302 -0
- package/tests/test-landscape.mjs +316 -0
- package/tests/test-lighting.mjs +428 -0
- package/tests/test-manage-asset.mjs +438 -0
- package/tests/test-manage-level.mjs +89 -0
- package/tests/test-materials.mjs +356 -0
- package/tests/test-niagara.mjs +185 -0
- package/tests/test-no-inline-python.mjs +122 -0
- package/tests/test-performance.mjs +539 -0
- package/tests/test-plugin-handshake.mjs +82 -0
- package/tests/test-runner.mjs +933 -0
- package/tests/test-sequence.mjs +104 -0
- package/tests/test-system.mjs +96 -0
- package/tests/test-wasm.mjs +283 -0
- package/tests/test-world-partition.mjs +215 -0
- package/tsconfig.json +3 -3
- package/vitest.config.ts +35 -0
- package/wasm/Cargo.lock +363 -0
- package/wasm/Cargo.toml +42 -0
- package/wasm/LICENSE +21 -0
- package/wasm/README.md +253 -0
- package/wasm/src/dependency_resolver.rs +377 -0
- package/wasm/src/lib.rs +153 -0
- package/wasm/src/property_parser.rs +271 -0
- package/wasm/src/transform_math.rs +396 -0
- package/wasm/tests/integration.rs +109 -0
- package/.github/workflows/smithery-build.yml +0 -29
- package/dist/prompts/index.d.ts +0 -21
- package/dist/prompts/index.js +0 -217
- package/dist/tools/build_environment_advanced.d.ts +0 -65
- package/dist/tools/build_environment_advanced.js +0 -633
- package/dist/tools/rc.d.ts +0 -110
- package/dist/tools/rc.js +0 -437
- package/dist/tools/visual.d.ts +0 -40
- package/dist/tools/visual.js +0 -282
- package/dist/utils/http.d.ts +0 -6
- package/dist/utils/http.js +0 -151
- package/dist/utils/python-output.d.ts +0 -18
- package/dist/utils/python-output.js +0 -290
- package/dist/utils/python.d.ts +0 -2
- package/dist/utils/python.js +0 -4
- package/dist/utils/stdio-redirect.d.ts +0 -2
- package/dist/utils/stdio-redirect.js +0 -20
- package/docs/unreal-tool-test-cases.md +0 -574
- package/smithery.yaml +0 -29
- package/src/prompts/index.ts +0 -249
- package/src/tools/build_environment_advanced.ts +0 -732
- package/src/tools/rc.ts +0 -515
- package/src/tools/visual.ts +0 -281
- package/src/utils/http.ts +0 -187
- package/src/utils/python-output.ts +0 -351
- package/src/utils/python.ts +0 -3
- package/src/utils/stdio-redirect.ts +0 -18
package/src/unreal-bridge.ts
CHANGED
|
@@ -1,334 +1,79 @@
|
|
|
1
|
-
import WebSocket from 'ws';
|
|
2
|
-
import { createHttpClient } from './utils/http.js';
|
|
3
1
|
import { Logger } from './utils/logger.js';
|
|
4
|
-
import { loadEnv } from './types/env.js';
|
|
5
2
|
import { ErrorHandler } from './utils/error-handler.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// Parameters?: any;
|
|
11
|
-
// }
|
|
12
|
-
|
|
13
|
-
interface RcCallBody {
|
|
14
|
-
objectPath: string; // e.g. "/Script/UnrealEd.Default__EditorAssetLibrary"
|
|
15
|
-
functionName: string; // e.g. "ListAssets"
|
|
16
|
-
parameters?: Record<string, any>;
|
|
17
|
-
generateTransaction?: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface CommandQueueItem {
|
|
21
|
-
command: () => Promise<any>;
|
|
22
|
-
resolve: (value: any) => void;
|
|
23
|
-
reject: (reason?: any) => void;
|
|
24
|
-
priority: number;
|
|
25
|
-
retryCount?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface PythonScriptTemplate {
|
|
29
|
-
name: string;
|
|
30
|
-
script: string;
|
|
31
|
-
params?: Record<string, any>;
|
|
32
|
-
}
|
|
3
|
+
import type { AutomationBridge } from './automation/index.js';
|
|
4
|
+
import { DEFAULT_AUTOMATION_HOST, DEFAULT_AUTOMATION_PORT } from './constants.js';
|
|
5
|
+
import { UnrealCommandQueue } from './utils/unreal-command-queue.js';
|
|
6
|
+
import { CommandValidator } from './utils/command-validator.js';
|
|
33
7
|
|
|
34
8
|
export class UnrealBridge {
|
|
35
|
-
private ws?: WebSocket;
|
|
36
|
-
private http = createHttpClient('');
|
|
37
|
-
private env = loadEnv();
|
|
38
9
|
private log = new Logger('UnrealBridge');
|
|
39
10
|
private connected = false;
|
|
40
|
-
private
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
private engineVersionCache?: { value: { version: string; major: number; minor: number; patch: number; isUE56OrAbove: boolean }; timestamp: number };
|
|
46
|
-
private readonly ENGINE_VERSION_TTL_MS = 5 * 60 * 1000;
|
|
47
|
-
|
|
48
|
-
// WebSocket health monitoring (best practice from WebSocket optimization guides)
|
|
49
|
-
private lastPongReceived = 0;
|
|
50
|
-
private pingInterval?: NodeJS.Timeout;
|
|
51
|
-
private readonly PING_INTERVAL_MS = 30000; // 30 seconds
|
|
52
|
-
private readonly PONG_TIMEOUT_MS = 10000; // 10 seconds
|
|
53
|
-
|
|
54
|
-
// Command queue for throttling
|
|
55
|
-
private commandQueue: CommandQueueItem[] = [];
|
|
56
|
-
private isProcessing = false;
|
|
57
|
-
private readonly MIN_COMMAND_DELAY = 100; // Increased to prevent console spam
|
|
58
|
-
private readonly MAX_COMMAND_DELAY = 500; // Maximum delay for heavy operations
|
|
59
|
-
private readonly STAT_COMMAND_DELAY = 300; // Special delay for stat commands to avoid warnings
|
|
60
|
-
private lastCommandTime = 0;
|
|
61
|
-
private lastStatCommandTime = 0; // Track stat commands separately
|
|
62
|
-
|
|
63
|
-
// Console object cache to reduce FindConsoleObject warnings
|
|
64
|
-
private consoleObjectCache = new Map<string, any>();
|
|
65
|
-
private readonly CONSOLE_CACHE_TTL = 300000; // 5 minutes TTL for cached objects
|
|
66
|
-
private pluginStatusCache = new Map<string, { enabled: boolean; timestamp: number }>();
|
|
67
|
-
private readonly PLUGIN_CACHE_TTL = 5 * 60 * 1000;
|
|
68
|
-
|
|
69
|
-
// Unsafe viewmodes that can cause crashes or instability via visualizeBuffer
|
|
70
|
-
private readonly UNSAFE_VIEWMODES = [
|
|
71
|
-
'BaseColor', 'WorldNormal', 'Metallic', 'Specular',
|
|
72
|
-
'Roughness',
|
|
73
|
-
'SubsurfaceColor',
|
|
74
|
-
'Opacity',
|
|
75
|
-
'LightComplexity', 'LightmapDensity',
|
|
76
|
-
'StationaryLightOverlap', 'CollisionPawn', 'CollisionVisibility'
|
|
77
|
-
];
|
|
78
|
-
private readonly HARD_BLOCKED_VIEWMODES = new Set([
|
|
79
|
-
'BaseColor', 'WorldNormal', 'Metallic', 'Specular', 'Roughness', 'SubsurfaceColor', 'Opacity'
|
|
80
|
-
]);
|
|
81
|
-
private readonly VIEWMODE_ALIASES = new Map<string, string>([
|
|
82
|
-
['lit', 'Lit'],
|
|
83
|
-
['unlit', 'Unlit'],
|
|
84
|
-
['wireframe', 'Wireframe'],
|
|
85
|
-
['brushwireframe', 'BrushWireframe'],
|
|
86
|
-
['brush_wireframe', 'BrushWireframe'],
|
|
87
|
-
['detaillighting', 'DetailLighting'],
|
|
88
|
-
['detail_lighting', 'DetailLighting'],
|
|
89
|
-
['lightingonly', 'LightingOnly'],
|
|
90
|
-
['lighting_only', 'LightingOnly'],
|
|
91
|
-
['lightonly', 'LightingOnly'],
|
|
92
|
-
['light_only', 'LightingOnly'],
|
|
93
|
-
['lightcomplexity', 'LightComplexity'],
|
|
94
|
-
['light_complexity', 'LightComplexity'],
|
|
95
|
-
['shadercomplexity', 'ShaderComplexity'],
|
|
96
|
-
['shader_complexity', 'ShaderComplexity'],
|
|
97
|
-
['lightmapdensity', 'LightmapDensity'],
|
|
98
|
-
['lightmap_density', 'LightmapDensity'],
|
|
99
|
-
['stationarylightoverlap', 'StationaryLightOverlap'],
|
|
100
|
-
['stationary_light_overlap', 'StationaryLightOverlap'],
|
|
101
|
-
['reflectionoverride', 'ReflectionOverride'],
|
|
102
|
-
['reflection_override', 'ReflectionOverride'],
|
|
103
|
-
['texeldensity', 'TexelDensity'],
|
|
104
|
-
['texel_density', 'TexelDensity'],
|
|
105
|
-
['vertexcolor', 'VertexColor'],
|
|
106
|
-
['vertex_color', 'VertexColor'],
|
|
107
|
-
['litdetail', 'DetailLighting'],
|
|
108
|
-
['lit_only', 'LightingOnly']
|
|
109
|
-
]);
|
|
110
|
-
|
|
111
|
-
// Python script templates for EditorLevelLibrary access
|
|
112
|
-
private readonly PYTHON_TEMPLATES: Record<string, PythonScriptTemplate> = {
|
|
113
|
-
GET_ALL_ACTORS: {
|
|
114
|
-
name: 'get_all_actors',
|
|
115
|
-
script: `
|
|
116
|
-
import unreal
|
|
117
|
-
import json
|
|
118
|
-
|
|
119
|
-
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
120
|
-
try:
|
|
121
|
-
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
122
|
-
if subsys:
|
|
123
|
-
actors = subsys.get_all_level_actors()
|
|
124
|
-
result = [{'name': a.get_name(), 'class': a.get_class().get_name(), 'path': a.get_path_name()} for a in actors]
|
|
125
|
-
print(f"RESULT:{json.dumps(result)}")
|
|
126
|
-
else:
|
|
127
|
-
print("RESULT:[]")
|
|
128
|
-
except Exception as e:
|
|
129
|
-
print(f"RESULT:{json.dumps({'error': str(e)})}")
|
|
130
|
-
`.trim()
|
|
131
|
-
},
|
|
132
|
-
SPAWN_ACTOR_AT_LOCATION: {
|
|
133
|
-
name: 'spawn_actor',
|
|
134
|
-
script: `
|
|
135
|
-
import unreal
|
|
136
|
-
import json
|
|
137
|
-
|
|
138
|
-
location = unreal.Vector({x}, {y}, {z})
|
|
139
|
-
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
140
|
-
|
|
141
|
-
try:
|
|
142
|
-
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
143
|
-
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
144
|
-
if subsys:
|
|
145
|
-
# Try to load asset class
|
|
146
|
-
actor_class = unreal.EditorAssetLibrary.load_asset("{class_path}")
|
|
147
|
-
if actor_class:
|
|
148
|
-
spawned = subsys.spawn_actor_from_object(actor_class, location, rotation)
|
|
149
|
-
if spawned:
|
|
150
|
-
print(f"RESULT:{json.dumps({'success': True, 'actor': spawned.get_name(), 'location': [{x}, {y}, {z}]}})}")
|
|
151
|
-
else:
|
|
152
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to spawn actor'})}")
|
|
153
|
-
else:
|
|
154
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to load actor class: {class_path}'})}")
|
|
155
|
-
else:
|
|
156
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'EditorActorSubsystem not available'})}")
|
|
157
|
-
except Exception as e:
|
|
158
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
159
|
-
`.trim()
|
|
160
|
-
},
|
|
161
|
-
DELETE_ACTOR: {
|
|
162
|
-
name: 'delete_actor',
|
|
163
|
-
script: `
|
|
164
|
-
import unreal
|
|
165
|
-
import json
|
|
166
|
-
|
|
167
|
-
try:
|
|
168
|
-
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
169
|
-
if subsys:
|
|
170
|
-
actors = subsys.get_all_level_actors()
|
|
171
|
-
found = False
|
|
172
|
-
for actor in actors:
|
|
173
|
-
if not actor:
|
|
174
|
-
continue
|
|
175
|
-
label = actor.get_actor_label()
|
|
176
|
-
name = actor.get_name()
|
|
177
|
-
if label == "{actor_name}" or name == "{actor_name}" or label.lower().startswith("{actor_name}".lower()+"_"):
|
|
178
|
-
success = subsys.destroy_actor(actor)
|
|
179
|
-
print(f"RESULT:{json.dumps({'success': success, 'deleted': label})}")
|
|
180
|
-
found = True
|
|
181
|
-
break
|
|
182
|
-
if not found:
|
|
183
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'Actor not found: {actor_name}'})}")
|
|
184
|
-
else:
|
|
185
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'EditorActorSubsystem not available'})}")
|
|
186
|
-
except Exception as e:
|
|
187
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
188
|
-
`.trim()
|
|
189
|
-
},
|
|
190
|
-
CREATE_ASSET: {
|
|
191
|
-
name: 'create_asset',
|
|
192
|
-
script: `
|
|
193
|
-
import unreal
|
|
194
|
-
import json
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
|
198
|
-
if asset_tools:
|
|
199
|
-
# Create factory based on asset type
|
|
200
|
-
factory_class = getattr(unreal, '{factory_class}', None)
|
|
201
|
-
asset_class = getattr(unreal, '{asset_class}', None)
|
|
202
|
-
|
|
203
|
-
if factory_class and asset_class:
|
|
204
|
-
factory = factory_class()
|
|
205
|
-
# Clean up the path - remove trailing slashes and normalize
|
|
206
|
-
package_path = "{package_path}".rstrip('/').replace('//', '/')
|
|
207
|
-
|
|
208
|
-
# Ensure package path is valid (starts with /Game or /Engine)
|
|
209
|
-
if not package_path.startswith('/Game') and not package_path.startswith('/Engine'):
|
|
210
|
-
if not package_path.startswith('/'):
|
|
211
|
-
package_path = f"/Game/{package_path}"
|
|
212
|
-
else:
|
|
213
|
-
package_path = f"/Game{package_path}"
|
|
214
|
-
|
|
215
|
-
# Create full asset path for verification
|
|
216
|
-
full_asset_path = f"{package_path}/{asset_name}" if package_path != "/Game" else f"/Game/{asset_name}"
|
|
217
|
-
|
|
218
|
-
# Create the asset with cleaned path
|
|
219
|
-
asset = asset_tools.create_asset("{asset_name}", package_path, asset_class, factory)
|
|
220
|
-
if asset:
|
|
221
|
-
# Save the asset
|
|
222
|
-
saved = unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
|
223
|
-
# Enhanced verification with retry logic
|
|
224
|
-
asset_path = asset.get_path_name()
|
|
225
|
-
verification_attempts = 0
|
|
226
|
-
max_verification_attempts = 5
|
|
227
|
-
asset_verified = False
|
|
228
|
-
|
|
229
|
-
while verification_attempts < max_verification_attempts and not asset_verified:
|
|
230
|
-
verification_attempts += 1
|
|
231
|
-
# Wait a bit for the asset to be fully saved
|
|
232
|
-
import time
|
|
233
|
-
time.sleep(0.1)
|
|
234
|
-
|
|
235
|
-
# Check if asset exists
|
|
236
|
-
asset_exists = unreal.EditorAssetLibrary.does_asset_exist(asset_path)
|
|
237
|
-
|
|
238
|
-
if asset_exists:
|
|
239
|
-
asset_verified = True
|
|
240
|
-
elif verification_attempts < max_verification_attempts:
|
|
241
|
-
# Try to reload the asset registry
|
|
242
|
-
try:
|
|
243
|
-
unreal.AssetRegistryHelpers.get_asset_registry().scan_modified_asset_files([asset_path])
|
|
244
|
-
except:
|
|
245
|
-
pass
|
|
246
|
-
|
|
247
|
-
if asset_verified:
|
|
248
|
-
print(f"RESULT:{json.dumps({'success': saved, 'path': asset_path, 'verified': True})}")
|
|
249
|
-
else:
|
|
250
|
-
print(f"RESULT:{json.dumps({'success': saved, 'path': asset_path, 'warning': 'Asset created but verification pending'})}")
|
|
251
|
-
else:
|
|
252
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to create asset'})}")
|
|
253
|
-
else:
|
|
254
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'Invalid factory or asset class'})}")
|
|
255
|
-
else:
|
|
256
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'AssetToolsHelpers not available'})}")
|
|
257
|
-
except Exception as e:
|
|
258
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
259
|
-
`.trim()
|
|
260
|
-
},
|
|
261
|
-
SET_VIEWPORT_CAMERA: {
|
|
262
|
-
name: 'set_viewport_camera',
|
|
263
|
-
script: `
|
|
264
|
-
import unreal
|
|
265
|
-
import json
|
|
266
|
-
|
|
267
|
-
location = unreal.Vector({x}, {y}, {z})
|
|
268
|
-
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
269
|
-
|
|
270
|
-
try:
|
|
271
|
-
# Use UnrealEditorSubsystem for viewport operations (UE5.1+)
|
|
272
|
-
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
273
|
-
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
274
|
-
|
|
275
|
-
if ues:
|
|
276
|
-
ues.set_level_viewport_camera_info(location, rotation)
|
|
277
|
-
try:
|
|
278
|
-
if les:
|
|
279
|
-
les.editor_invalidate_viewports()
|
|
280
|
-
except Exception:
|
|
281
|
-
pass
|
|
282
|
-
print(f"RESULT:{json.dumps({'success': True, 'location': [{x}, {y}, {z}], 'rotation': [{pitch}, {yaw}, {roll}]}})}")
|
|
283
|
-
else:
|
|
284
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'UnrealEditorSubsystem not available'})}")
|
|
285
|
-
except Exception as e:
|
|
286
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
287
|
-
`.trim()
|
|
288
|
-
},
|
|
289
|
-
BUILD_LIGHTING: {
|
|
290
|
-
name: 'build_lighting',
|
|
291
|
-
script: `
|
|
292
|
-
import unreal
|
|
293
|
-
import json
|
|
294
|
-
|
|
295
|
-
try:
|
|
296
|
-
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
297
|
-
if les:
|
|
298
|
-
# Use UE 5.6 enhanced lighting quality settings
|
|
299
|
-
quality_map = {
|
|
300
|
-
'Preview': unreal.LightingBuildQuality.PREVIEW,
|
|
301
|
-
'Medium': unreal.LightingBuildQuality.MEDIUM,
|
|
302
|
-
'High': unreal.LightingBuildQuality.HIGH,
|
|
303
|
-
'Production': unreal.LightingBuildQuality.PRODUCTION
|
|
304
|
-
}
|
|
305
|
-
q = quality_map.get('{quality}', unreal.LightingBuildQuality.PREVIEW)
|
|
306
|
-
les.build_light_maps(q, True)
|
|
307
|
-
print(f"RESULT:{json.dumps({'success': True, 'quality': '{quality}', 'method': 'LevelEditorSubsystem'})}")
|
|
308
|
-
else:
|
|
309
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': 'LevelEditorSubsystem not available'})}")
|
|
310
|
-
except Exception as e:
|
|
311
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
312
|
-
`.trim()
|
|
313
|
-
},
|
|
314
|
-
SAVE_ALL_DIRTY_PACKAGES: {
|
|
315
|
-
name: 'save_dirty_packages',
|
|
316
|
-
script: `
|
|
317
|
-
import unreal
|
|
318
|
-
import json
|
|
319
|
-
|
|
320
|
-
try:
|
|
321
|
-
# Use UE 5.6 enhanced saving with better error handling
|
|
322
|
-
saved = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
|
323
|
-
print(f"RESULT:{json.dumps({'success': bool(saved), 'saved_count': saved if isinstance(saved, int) else 0, 'message': 'All dirty packages saved'})}")
|
|
324
|
-
except Exception as e:
|
|
325
|
-
print(f"RESULT:{json.dumps({'success': False, 'error': str(e), 'message': 'Failed to save dirty packages'})}")
|
|
326
|
-
`.trim()
|
|
327
|
-
}
|
|
11
|
+
private automationBridge?: AutomationBridge;
|
|
12
|
+
private automationBridgeListeners?: {
|
|
13
|
+
connected: (info: any) => void;
|
|
14
|
+
disconnected: (info: any) => void;
|
|
15
|
+
handshakeFailed: (info: any) => void;
|
|
328
16
|
};
|
|
329
17
|
|
|
18
|
+
// Command queue for throttling
|
|
19
|
+
private commandQueue = new UnrealCommandQueue();
|
|
20
|
+
|
|
330
21
|
get isConnected() { return this.connected; }
|
|
331
|
-
|
|
22
|
+
|
|
23
|
+
setAutomationBridge(automationBridge?: AutomationBridge): void {
|
|
24
|
+
if (this.automationBridge && this.automationBridgeListeners) {
|
|
25
|
+
this.automationBridge.off('connected', this.automationBridgeListeners.connected);
|
|
26
|
+
this.automationBridge.off('disconnected', this.automationBridgeListeners.disconnected);
|
|
27
|
+
this.automationBridge.off('handshakeFailed', this.automationBridgeListeners.handshakeFailed);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.automationBridge = automationBridge;
|
|
31
|
+
this.automationBridgeListeners = undefined;
|
|
32
|
+
|
|
33
|
+
if (!automationBridge) {
|
|
34
|
+
this.connected = false;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const onConnected = (info: any) => {
|
|
39
|
+
this.connected = true;
|
|
40
|
+
this.log.debug('Automation bridge connected', info);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const onDisconnected = (info: any) => {
|
|
44
|
+
this.connected = false;
|
|
45
|
+
this.log.debug('Automation bridge disconnected', info);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const onHandshakeFailed = (info: any) => {
|
|
49
|
+
this.connected = false;
|
|
50
|
+
this.log.warn('Automation bridge handshake failed', info);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
automationBridge.on('connected', onConnected);
|
|
54
|
+
automationBridge.on('disconnected', onDisconnected);
|
|
55
|
+
automationBridge.on('handshakeFailed', onHandshakeFailed);
|
|
56
|
+
|
|
57
|
+
this.automationBridgeListeners = {
|
|
58
|
+
connected: onConnected,
|
|
59
|
+
disconnected: onDisconnected,
|
|
60
|
+
handshakeFailed: onHandshakeFailed
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
this.connected = automationBridge.isConnected();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the automation bridge instance safely.
|
|
68
|
+
* Throws if not configured, but does not check connection status (use isConnected for that).
|
|
69
|
+
*/
|
|
70
|
+
getAutomationBridge(): AutomationBridge {
|
|
71
|
+
if (!this.automationBridge) {
|
|
72
|
+
throw new Error('Automation bridge is not configured');
|
|
73
|
+
}
|
|
74
|
+
return this.automationBridge;
|
|
75
|
+
}
|
|
76
|
+
|
|
332
77
|
/**
|
|
333
78
|
* Attempt to connect with exponential backoff retry strategy
|
|
334
79
|
* Uses optimized retry pattern from TypeScript best practices
|
|
@@ -339,36 +84,54 @@ except Exception as e:
|
|
|
339
84
|
*/
|
|
340
85
|
private connectPromise?: Promise<void>;
|
|
341
86
|
|
|
342
|
-
async tryConnect(maxAttempts: number = 3, timeoutMs: number =
|
|
343
|
-
if (
|
|
87
|
+
async tryConnect(maxAttempts: number = 3, timeoutMs: number = 15000, retryDelayMs: number = 3000): Promise<boolean> {
|
|
88
|
+
if (process.env.MOCK_UNREAL_CONNECTION === 'true') {
|
|
89
|
+
this.log.info('🔌 MOCK MODE: Simulating active connection');
|
|
90
|
+
this.connected = true;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.connected && this.automationBridge?.isConnected()) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!this.automationBridge) {
|
|
99
|
+
this.log.warn('Automation bridge is not configured; cannot establish connection.');
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.automationBridge.isConnected()) {
|
|
104
|
+
this.connected = true;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
344
107
|
|
|
345
108
|
if (this.connectPromise) {
|
|
346
109
|
try {
|
|
347
110
|
await this.connectPromise;
|
|
348
|
-
} catch {
|
|
349
|
-
// swallow, we'll return connected flag
|
|
350
|
-
}
|
|
111
|
+
} catch { }
|
|
351
112
|
return this.connected;
|
|
352
113
|
}
|
|
353
114
|
|
|
354
|
-
// Use ErrorHandler's retryWithBackoff for consistent retry behavior
|
|
355
115
|
this.connectPromise = ErrorHandler.retryWithBackoff(
|
|
356
|
-
() =>
|
|
116
|
+
() => {
|
|
117
|
+
const envTimeout = process.env.UNREAL_CONNECTION_TIMEOUT ? parseInt(process.env.UNREAL_CONNECTION_TIMEOUT, 10) : 30000;
|
|
118
|
+
const actualTimeout = envTimeout > 0 ? envTimeout : timeoutMs;
|
|
119
|
+
return this.connect(actualTimeout);
|
|
120
|
+
},
|
|
357
121
|
{
|
|
358
|
-
maxRetries: maxAttempts - 1,
|
|
122
|
+
maxRetries: Math.max(0, maxAttempts - 1),
|
|
359
123
|
initialDelay: retryDelayMs,
|
|
360
124
|
maxDelay: 10000,
|
|
361
125
|
backoffMultiplier: 1.5,
|
|
362
|
-
shouldRetry: (error) => {
|
|
363
|
-
// Only retry on connection-related errors
|
|
126
|
+
shouldRetry: (error: any) => {
|
|
364
127
|
const msg = (error as Error)?.message?.toLowerCase() || '';
|
|
365
|
-
return msg.includes('timeout') || msg.includes('
|
|
128
|
+
return msg.includes('timeout') || msg.includes('connect') || msg.includes('automation');
|
|
366
129
|
}
|
|
367
130
|
}
|
|
368
|
-
).
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
this.log.warn(
|
|
131
|
+
).catch((err: any) => {
|
|
132
|
+
this.log.warn(`Automation bridge connection failed after ${maxAttempts} attempts:`, err.message);
|
|
133
|
+
this.log.warn('⚠️ Ensure Unreal Editor is running with MCP Automation Bridge plugin enabled');
|
|
134
|
+
this.log.warn(`⚠️ Plugin should listen on ws://${DEFAULT_AUTOMATION_HOST}:${DEFAULT_AUTOMATION_PORT} for MCP server connections`);
|
|
372
135
|
});
|
|
373
136
|
|
|
374
137
|
try {
|
|
@@ -377,553 +140,351 @@ except Exception as e:
|
|
|
377
140
|
this.connectPromise = undefined;
|
|
378
141
|
}
|
|
379
142
|
|
|
143
|
+
this.connected = this.automationBridge?.isConnected() ?? false;
|
|
380
144
|
return this.connected;
|
|
381
145
|
}
|
|
382
146
|
|
|
383
|
-
async connect(timeoutMs: number =
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
147
|
+
async connect(timeoutMs: number = 15000): Promise<void> {
|
|
148
|
+
const automationBridge = this.automationBridge;
|
|
149
|
+
if (!automationBridge) {
|
|
150
|
+
throw new Error('Automation bridge not configured');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (automationBridge.isConnected()) {
|
|
154
|
+
this.connected = true;
|
|
387
155
|
return;
|
|
388
156
|
}
|
|
389
157
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
158
|
+
// Start the bridge connection if it's not active
|
|
159
|
+
// This supports lazy connection where the bridge doesn't start until a tool is used
|
|
160
|
+
automationBridge.start();
|
|
393
161
|
|
|
394
|
-
|
|
395
|
-
|
|
162
|
+
const success = await this.waitForAutomationConnection(timeoutMs);
|
|
163
|
+
if (!success) {
|
|
164
|
+
throw new Error('Automation bridge connection timeout');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.connected = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async waitForAutomationConnection(timeoutMs: number): Promise<boolean> {
|
|
171
|
+
const automationBridge = this.automationBridge;
|
|
172
|
+
if (!automationBridge) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (automationBridge.isConnected()) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
396
179
|
|
|
397
|
-
|
|
398
|
-
if (!this.ws) return reject(new Error('WS not created'));
|
|
399
|
-
|
|
400
|
-
// Guard against double-resolution/rejection
|
|
180
|
+
return new Promise<boolean>((resolve) => {
|
|
401
181
|
let settled = false;
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const timeout = setTimeout(() => {
|
|
407
|
-
this.log.warn(`Connection timeout after ${timeoutMs}ms`);
|
|
408
|
-
if (this.ws) {
|
|
409
|
-
try {
|
|
410
|
-
// Attach a temporary error handler to avoid unhandled 'error' events on abort
|
|
411
|
-
this.ws.on('error', () => {});
|
|
412
|
-
// Prefer graceful close; terminate as a fallback
|
|
413
|
-
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
|
|
414
|
-
try { this.ws.close(); } catch {}
|
|
415
|
-
try { this.ws.terminate(); } catch {}
|
|
416
|
-
}
|
|
417
|
-
} finally {
|
|
418
|
-
try { this.ws.removeAllListeners(); } catch {}
|
|
419
|
-
this.ws = undefined;
|
|
420
|
-
}
|
|
182
|
+
|
|
183
|
+
const cleanup = () => {
|
|
184
|
+
if (settled) {
|
|
185
|
+
return;
|
|
421
186
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
clearTimeout(
|
|
428
|
-
this.connected = true;
|
|
429
|
-
this.log.info('Connected to Unreal Remote Control');
|
|
430
|
-
this.startCommandProcessor(); // Start command processor on connect
|
|
431
|
-
safeResolve();
|
|
187
|
+
settled = true;
|
|
188
|
+
automationBridge.off('connected', onConnected);
|
|
189
|
+
automationBridge.off('handshakeFailed', onHandshakeFailed);
|
|
190
|
+
automationBridge.off('error', onError);
|
|
191
|
+
automationBridge.off('disconnected', onDisconnected);
|
|
192
|
+
clearTimeout(timer);
|
|
432
193
|
};
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
this.log.debug(`WebSocket error during connect: ${(err && (err as any).code) || ''} ${err.message}`);
|
|
439
|
-
if (this.ws) {
|
|
440
|
-
try {
|
|
441
|
-
// Attach a temporary error handler to avoid unhandled 'error' events while aborting
|
|
442
|
-
this.ws.on('error', () => {});
|
|
443
|
-
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
444
|
-
try { this.ws.close(); } catch {}
|
|
445
|
-
try { this.ws.terminate(); } catch {}
|
|
446
|
-
}
|
|
447
|
-
} finally {
|
|
448
|
-
try { this.ws.removeAllListeners(); } catch {}
|
|
449
|
-
this.ws = undefined;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
safeReject(new Error(`Failed to connect: ${err.message}`));
|
|
194
|
+
|
|
195
|
+
const onConnected = (info: any) => {
|
|
196
|
+
cleanup();
|
|
197
|
+
this.log.debug('Automation bridge connected while waiting', info);
|
|
198
|
+
resolve(true);
|
|
453
199
|
};
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// Normal close after connection was established
|
|
462
|
-
this.connected = false;
|
|
463
|
-
this.ws = undefined;
|
|
464
|
-
this.log.warn('WebSocket closed');
|
|
465
|
-
if (this.autoReconnectEnabled) {
|
|
466
|
-
this.scheduleReconnect();
|
|
467
|
-
}
|
|
468
|
-
}
|
|
200
|
+
|
|
201
|
+
const onHandshakeFailed = (info: any) => {
|
|
202
|
+
this.log.warn('Automation bridge handshake failed while waiting', info);
|
|
203
|
+
// We don't resolve false immediately here? The original code didn't.
|
|
204
|
+
// But handshake failed usually means we should stop waiting.
|
|
205
|
+
cleanup();
|
|
206
|
+
resolve(false);
|
|
469
207
|
};
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
this.log.debug('WS message', msg);
|
|
476
|
-
} catch (_e) {
|
|
477
|
-
// Noise reduction: keep at debug and do nothing on parse errors
|
|
478
|
-
}
|
|
208
|
+
|
|
209
|
+
const onError = (err: any) => {
|
|
210
|
+
this.log.warn('Automation bridge error while waiting', err);
|
|
211
|
+
cleanup();
|
|
212
|
+
resolve(false);
|
|
479
213
|
};
|
|
480
|
-
|
|
481
|
-
// Attach listeners
|
|
482
|
-
this.ws.once('open', onOpen);
|
|
483
|
-
this.ws.once('error', onError);
|
|
484
|
-
this.ws.on('close', onClose);
|
|
485
|
-
this.ws.on('message', onMessage);
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
214
|
|
|
215
|
+
const onDisconnected = (info: any) => {
|
|
216
|
+
this.log.warn('Automation bridge disconnected while waiting', info);
|
|
217
|
+
cleanup();
|
|
218
|
+
resolve(false);
|
|
219
|
+
};
|
|
489
220
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
cleanup();
|
|
223
|
+
resolve(false);
|
|
224
|
+
}, Math.max(0, timeoutMs));
|
|
225
|
+
|
|
226
|
+
automationBridge.on('connected', onConnected);
|
|
227
|
+
automationBridge.on('handshakeFailed', onHandshakeFailed);
|
|
228
|
+
automationBridge.on('error', onError);
|
|
229
|
+
automationBridge.on('disconnected', onDisconnected);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
495
232
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
233
|
+
async getObjectProperty(params: {
|
|
234
|
+
objectPath: string;
|
|
235
|
+
propertyName: string;
|
|
236
|
+
timeoutMs?: number;
|
|
237
|
+
allowAlternate?: boolean;
|
|
238
|
+
}): Promise<Record<string, any>> {
|
|
239
|
+
const { objectPath, propertyName, timeoutMs } = params;
|
|
240
|
+
if (!objectPath || typeof objectPath !== 'string') {
|
|
241
|
+
throw new Error('Invalid objectPath: must be a non-empty string');
|
|
503
242
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
let CALL_TIMEOUT = 10000; // Default 10 seconds timeout
|
|
507
|
-
const longRunningTimeout = 10 * 60 * 1000; // 10 minutes for heavy editor jobs
|
|
508
|
-
|
|
509
|
-
// Use payload contents to detect long-running editor operations
|
|
510
|
-
let payloadSignature = '';
|
|
511
|
-
if (typeof payload === 'string') {
|
|
512
|
-
payloadSignature = payload;
|
|
513
|
-
} else if (payload && typeof payload === 'object') {
|
|
514
|
-
try {
|
|
515
|
-
payloadSignature = JSON.stringify(payload);
|
|
516
|
-
} catch {
|
|
517
|
-
payloadSignature = '';
|
|
518
|
-
}
|
|
243
|
+
if (!propertyName || typeof propertyName !== 'string') {
|
|
244
|
+
throw new Error('Invalid propertyName: must be a non-empty string');
|
|
519
245
|
}
|
|
520
246
|
|
|
521
|
-
|
|
522
|
-
let sanitizedPayload = payload;
|
|
523
|
-
if (payload && typeof payload === 'object' && '__callTimeoutMs' in payload) {
|
|
524
|
-
const overrideRaw = (payload as any).__callTimeoutMs;
|
|
525
|
-
const overrideMs = typeof overrideRaw === 'number'
|
|
526
|
-
? overrideRaw
|
|
527
|
-
: Number.parseInt(String(overrideRaw), 10);
|
|
528
|
-
if (Number.isFinite(overrideMs) && overrideMs > 0) {
|
|
529
|
-
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, overrideMs);
|
|
530
|
-
}
|
|
531
|
-
sanitizedPayload = { ...(payload as any) };
|
|
532
|
-
delete (sanitizedPayload as any).__callTimeoutMs;
|
|
533
|
-
}
|
|
247
|
+
const bridge = this.automationBridge;
|
|
534
248
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
249
|
+
if (process.env.MOCK_UNREAL_CONNECTION === 'true') {
|
|
250
|
+
return {
|
|
251
|
+
success: true,
|
|
252
|
+
objectPath,
|
|
253
|
+
propertyName,
|
|
254
|
+
value: 'MockValue',
|
|
255
|
+
propertyValue: 'MockValue',
|
|
256
|
+
transport: 'mock_bridge',
|
|
257
|
+
message: 'Mock property read successful'
|
|
258
|
+
};
|
|
541
259
|
}
|
|
542
260
|
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (longRunningPatterns.some(pattern => pattern.test(payloadSignature))) {
|
|
552
|
-
if (CALL_TIMEOUT < longRunningTimeout) {
|
|
553
|
-
this.log.debug(`Detected long-running lighting operation, extending HTTP timeout to ${longRunningTimeout}ms`);
|
|
554
|
-
}
|
|
555
|
-
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, longRunningTimeout);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// CRITICAL: Intercept and block dangerous console commands at HTTP level
|
|
560
|
-
if (url === '/remote/object/call' && (payload as any)?.functionName === 'ExecuteConsoleCommand') {
|
|
561
|
-
const command = (payload as any)?.parameters?.Command;
|
|
562
|
-
if (command && typeof command === 'string') {
|
|
563
|
-
const cmdLower = command.trim().toLowerCase();
|
|
564
|
-
|
|
565
|
-
// List of commands that cause crashes
|
|
566
|
-
const crashCommands = [
|
|
567
|
-
'buildpaths', // Causes access violation 0x0000000000000060
|
|
568
|
-
'rebuildnavigation', // Can crash without nav system
|
|
569
|
-
'buildhierarchicallod', // Can crash without proper setup
|
|
570
|
-
'buildlandscapeinfo', // Can crash without landscape
|
|
571
|
-
'rebuildselectednavigation' // Nav-related crash
|
|
572
|
-
];
|
|
573
|
-
|
|
574
|
-
// Check if this is a crash-inducing command
|
|
575
|
-
if (crashCommands.some(dangerous => cmdLower === dangerous || cmdLower.startsWith(dangerous + ' '))) {
|
|
576
|
-
this.log.warn(`BLOCKED dangerous command that causes crashes: ${command}`);
|
|
577
|
-
// Return a safe error response instead of executing
|
|
578
|
-
return {
|
|
579
|
-
success: false,
|
|
580
|
-
error: `Command '${command}' blocked: This command can cause Unreal Engine to crash. Use the Python API alternatives instead.`
|
|
581
|
-
} as any;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// Also block other dangerous commands
|
|
585
|
-
const dangerousPatterns = [
|
|
586
|
-
'quit', 'exit', 'r.gpucrash', 'debug crash',
|
|
587
|
-
'viewmode visualizebuffer' // These can crash in certain states
|
|
588
|
-
];
|
|
589
|
-
|
|
590
|
-
if (dangerousPatterns.some(pattern => cmdLower.includes(pattern))) {
|
|
591
|
-
this.log.warn(`BLOCKED potentially dangerous command: ${command}`);
|
|
592
|
-
return {
|
|
593
|
-
success: false,
|
|
594
|
-
error: `Command '${command}' blocked for safety.`
|
|
595
|
-
} as any;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
261
|
+
if (!bridge || typeof bridge.sendAutomationRequest !== 'function') {
|
|
262
|
+
return {
|
|
263
|
+
success: false,
|
|
264
|
+
objectPath,
|
|
265
|
+
propertyName,
|
|
266
|
+
error: 'Automation bridge not connected',
|
|
267
|
+
transport: 'automation_bridge'
|
|
268
|
+
};
|
|
598
269
|
}
|
|
599
|
-
|
|
600
|
-
// Retry logic with exponential backoff and timeout
|
|
601
|
-
let lastError: any;
|
|
602
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
603
|
-
try {
|
|
604
|
-
// For GET requests, send payload as query parameters (not in body)
|
|
605
|
-
const config: any = { url, method, timeout: CALL_TIMEOUT };
|
|
606
|
-
if (method === 'GET' && sanitizedPayload && typeof sanitizedPayload === 'object') {
|
|
607
|
-
config.params = sanitizedPayload;
|
|
608
|
-
} else if (sanitizedPayload !== undefined) {
|
|
609
|
-
config.data = sanitizedPayload;
|
|
610
|
-
}
|
|
611
270
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
clearTimeout(timer);
|
|
622
|
-
resolve(result);
|
|
623
|
-
}).catch(err => {
|
|
624
|
-
clearTimeout(timer);
|
|
625
|
-
reject(err);
|
|
626
|
-
});
|
|
627
|
-
});
|
|
628
|
-
const ms = Date.now() - started;
|
|
629
|
-
|
|
630
|
-
// Add connection health check for long-running requests
|
|
631
|
-
if (ms > 5000) {
|
|
632
|
-
this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms (long request)`);
|
|
633
|
-
} else {
|
|
634
|
-
this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms`);
|
|
635
|
-
}
|
|
271
|
+
try {
|
|
272
|
+
const response = await bridge.sendAutomationRequest(
|
|
273
|
+
'get_object_property',
|
|
274
|
+
{
|
|
275
|
+
objectPath,
|
|
276
|
+
propertyName
|
|
277
|
+
},
|
|
278
|
+
timeoutMs ? { timeoutMs } : undefined
|
|
279
|
+
);
|
|
636
280
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
281
|
+
const success = response.success !== false;
|
|
282
|
+
const rawResult =
|
|
283
|
+
response.result && typeof response.result === 'object'
|
|
284
|
+
? { ...(response.result as Record<string, unknown>) }
|
|
285
|
+
: response.result;
|
|
286
|
+
const value =
|
|
287
|
+
(rawResult as any)?.value ??
|
|
288
|
+
(rawResult as any)?.propertyValue ??
|
|
289
|
+
(success ? rawResult : undefined);
|
|
290
|
+
|
|
291
|
+
if (success) {
|
|
292
|
+
return {
|
|
293
|
+
success: true,
|
|
294
|
+
objectPath,
|
|
295
|
+
propertyName,
|
|
296
|
+
value,
|
|
297
|
+
propertyValue: value,
|
|
298
|
+
transport: 'automation_bridge',
|
|
299
|
+
message: response.message,
|
|
300
|
+
warnings: Array.isArray((rawResult as any)?.warnings)
|
|
301
|
+
? (rawResult as any).warnings
|
|
302
|
+
: undefined,
|
|
303
|
+
raw: rawResult,
|
|
304
|
+
bridge: {
|
|
305
|
+
requestId: response.requestId,
|
|
306
|
+
success: true,
|
|
307
|
+
error: response.error
|
|
656
308
|
}
|
|
657
|
-
}
|
|
309
|
+
};
|
|
658
310
|
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
objectPath,
|
|
315
|
+
propertyName,
|
|
316
|
+
error: response.error || response.message || 'AUTOMATION_BRIDGE_FAILURE',
|
|
317
|
+
transport: 'automation_bridge',
|
|
318
|
+
raw: rawResult,
|
|
319
|
+
bridge: {
|
|
320
|
+
requestId: response.requestId,
|
|
321
|
+
success: false,
|
|
322
|
+
error: response.error
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
} catch (err) {
|
|
326
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
327
|
+
return {
|
|
328
|
+
success: false,
|
|
329
|
+
objectPath,
|
|
330
|
+
propertyName,
|
|
331
|
+
error: message,
|
|
332
|
+
transport: 'automation_bridge'
|
|
333
|
+
};
|
|
659
334
|
}
|
|
660
|
-
|
|
661
|
-
throw lastError;
|
|
662
335
|
}
|
|
663
336
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
337
|
+
async setObjectProperty(params: {
|
|
338
|
+
objectPath: string;
|
|
339
|
+
propertyName: string;
|
|
340
|
+
value: unknown;
|
|
341
|
+
markDirty?: boolean;
|
|
342
|
+
timeoutMs?: number;
|
|
343
|
+
allowAlternate?: boolean;
|
|
344
|
+
}): Promise<Record<string, any>> {
|
|
345
|
+
const { objectPath, propertyName, value, markDirty, timeoutMs } = params;
|
|
346
|
+
if (!objectPath || typeof objectPath !== 'string') {
|
|
347
|
+
throw new Error('Invalid objectPath: must be a non-empty string');
|
|
667
348
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
if (typeof raw === 'string') {
|
|
672
|
-
fragments.push(raw);
|
|
349
|
+
if (!propertyName || typeof propertyName !== 'string') {
|
|
350
|
+
throw new Error('Invalid propertyName: must be a non-empty string');
|
|
673
351
|
}
|
|
674
352
|
|
|
675
|
-
|
|
676
|
-
fragments.push(raw.Output);
|
|
677
|
-
}
|
|
353
|
+
const bridge = this.automationBridge;
|
|
678
354
|
|
|
679
|
-
if (
|
|
680
|
-
|
|
355
|
+
if (process.env.MOCK_UNREAL_CONNECTION === 'true') {
|
|
356
|
+
return {
|
|
357
|
+
success: true,
|
|
358
|
+
objectPath,
|
|
359
|
+
propertyName,
|
|
360
|
+
message: 'Mock property set successful',
|
|
361
|
+
transport: 'mock_bridge'
|
|
362
|
+
};
|
|
681
363
|
}
|
|
682
364
|
|
|
683
|
-
if (
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
365
|
+
if (!bridge || typeof bridge.sendAutomationRequest !== 'function') {
|
|
366
|
+
return {
|
|
367
|
+
success: false,
|
|
368
|
+
objectPath,
|
|
369
|
+
propertyName,
|
|
370
|
+
error: 'Automation bridge not connected',
|
|
371
|
+
transport: 'automation_bridge'
|
|
372
|
+
};
|
|
692
373
|
}
|
|
693
374
|
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
375
|
+
const payload: Record<string, unknown> = {
|
|
376
|
+
objectPath,
|
|
377
|
+
propertyName,
|
|
378
|
+
value
|
|
379
|
+
};
|
|
380
|
+
if (markDirty !== undefined) {
|
|
381
|
+
payload.markDirty = Boolean(markDirty);
|
|
698
382
|
}
|
|
699
383
|
|
|
700
384
|
try {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
async ensurePluginsEnabled(pluginNames: string[], context?: string): Promise<string[]> {
|
|
708
|
-
if (!pluginNames || pluginNames.length === 0) {
|
|
709
|
-
return [];
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
const now = Date.now();
|
|
713
|
-
const pluginsToCheck = pluginNames.filter((name) => {
|
|
714
|
-
const cached = this.pluginStatusCache.get(name);
|
|
715
|
-
if (!cached) return true;
|
|
716
|
-
if (now - cached.timestamp > this.PLUGIN_CACHE_TTL) {
|
|
717
|
-
this.pluginStatusCache.delete(name);
|
|
718
|
-
return true;
|
|
719
|
-
}
|
|
720
|
-
return false;
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
if (pluginsToCheck.length > 0) {
|
|
724
|
-
const python = `
|
|
725
|
-
import unreal
|
|
726
|
-
import json
|
|
727
|
-
|
|
728
|
-
plugins = ${JSON.stringify(pluginsToCheck)}
|
|
729
|
-
status = {}
|
|
730
|
-
|
|
731
|
-
def get_plugin_manager():
|
|
732
|
-
try:
|
|
733
|
-
return unreal.PluginManager.get()
|
|
734
|
-
except AttributeError:
|
|
735
|
-
return None
|
|
736
|
-
except Exception:
|
|
737
|
-
return None
|
|
738
|
-
|
|
739
|
-
def get_plugins_subsystem():
|
|
740
|
-
try:
|
|
741
|
-
return unreal.get_editor_subsystem(unreal.PluginsEditorSubsystem)
|
|
742
|
-
except AttributeError:
|
|
743
|
-
pass
|
|
744
|
-
except Exception:
|
|
745
|
-
pass
|
|
746
|
-
try:
|
|
747
|
-
return unreal.PluginsSubsystem()
|
|
748
|
-
except Exception:
|
|
749
|
-
return None
|
|
750
|
-
|
|
751
|
-
pm = get_plugin_manager()
|
|
752
|
-
ps = get_plugins_subsystem()
|
|
753
|
-
|
|
754
|
-
def is_enabled(plugin_name):
|
|
755
|
-
if pm:
|
|
756
|
-
try:
|
|
757
|
-
if pm.is_plugin_enabled(plugin_name):
|
|
758
|
-
return True
|
|
759
|
-
except Exception:
|
|
760
|
-
try:
|
|
761
|
-
plugin = pm.find_plugin(plugin_name)
|
|
762
|
-
if plugin and plugin.is_enabled():
|
|
763
|
-
return True
|
|
764
|
-
except Exception:
|
|
765
|
-
pass
|
|
766
|
-
if ps:
|
|
767
|
-
try:
|
|
768
|
-
return bool(ps.is_plugin_enabled(plugin_name))
|
|
769
|
-
except Exception:
|
|
770
|
-
try:
|
|
771
|
-
plugin = ps.find_plugin(plugin_name)
|
|
772
|
-
if plugin and plugin.is_enabled():
|
|
773
|
-
return True
|
|
774
|
-
except Exception:
|
|
775
|
-
pass
|
|
776
|
-
return False
|
|
777
|
-
|
|
778
|
-
for plugin_name in plugins:
|
|
779
|
-
enabled = False
|
|
780
|
-
try:
|
|
781
|
-
enabled = is_enabled(plugin_name)
|
|
782
|
-
except Exception:
|
|
783
|
-
enabled = False
|
|
784
|
-
status[plugin_name] = bool(enabled)
|
|
785
|
-
|
|
786
|
-
print('RESULT:' + json.dumps(status))
|
|
787
|
-
`.trim();
|
|
385
|
+
const response = await bridge.sendAutomationRequest(
|
|
386
|
+
'set_object_property',
|
|
387
|
+
payload,
|
|
388
|
+
timeoutMs ? { timeoutMs } : undefined
|
|
389
|
+
);
|
|
788
390
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
391
|
+
const success = response.success !== false;
|
|
392
|
+
const rawResult =
|
|
393
|
+
response.result && typeof response.result === 'object'
|
|
394
|
+
? { ...(response.result as Record<string, unknown>) }
|
|
395
|
+
: response.result;
|
|
396
|
+
|
|
397
|
+
if (success) {
|
|
398
|
+
return {
|
|
399
|
+
success: true,
|
|
400
|
+
objectPath,
|
|
401
|
+
propertyName,
|
|
402
|
+
message:
|
|
403
|
+
response.message ||
|
|
404
|
+
(typeof (rawResult as any)?.message === 'string' ? (rawResult as any).message : undefined),
|
|
405
|
+
transport: 'automation_bridge',
|
|
406
|
+
raw: rawResult,
|
|
407
|
+
bridge: {
|
|
408
|
+
requestId: response.requestId,
|
|
409
|
+
success: true,
|
|
410
|
+
error: response.error
|
|
795
411
|
}
|
|
796
|
-
}
|
|
797
|
-
this.log.warn('Failed to parse plugin status response', { context, pluginsToCheck });
|
|
798
|
-
}
|
|
799
|
-
} catch (error) {
|
|
800
|
-
this.log.warn('Plugin status check failed', { context, pluginsToCheck, error: (error as Error)?.message ?? error });
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
for (const name of pluginNames) {
|
|
805
|
-
if (!this.pluginStatusCache.has(name)) {
|
|
806
|
-
this.pluginStatusCache.set(name, { enabled: false, timestamp: now });
|
|
412
|
+
};
|
|
807
413
|
}
|
|
808
|
-
}
|
|
809
414
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
415
|
+
return {
|
|
416
|
+
success: false,
|
|
417
|
+
objectPath,
|
|
418
|
+
propertyName,
|
|
419
|
+
error: response.error || response.message || 'AUTOMATION_BRIDGE_FAILURE',
|
|
420
|
+
transport: 'automation_bridge',
|
|
421
|
+
raw: rawResult,
|
|
422
|
+
bridge: {
|
|
423
|
+
requestId: response.requestId,
|
|
424
|
+
success: false,
|
|
425
|
+
error: response.error
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
} catch (err) {
|
|
429
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
430
|
+
return {
|
|
431
|
+
success: false,
|
|
432
|
+
objectPath,
|
|
433
|
+
propertyName,
|
|
434
|
+
error: message,
|
|
435
|
+
transport: 'automation_bridge'
|
|
436
|
+
};
|
|
813
437
|
}
|
|
814
|
-
return missing;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// Generic function call via Remote Control HTTP API
|
|
818
|
-
async call(body: RcCallBody): Promise<any> {
|
|
819
|
-
if (!this.connected) throw new Error('Not connected to Unreal Engine');
|
|
820
|
-
// Using HTTP endpoint /remote/object/call
|
|
821
|
-
const result = await this.httpCall<any>('/remote/object/call', 'PUT', {
|
|
822
|
-
generateTransaction: false,
|
|
823
|
-
...body
|
|
824
|
-
});
|
|
825
|
-
return result;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
async getExposed(): Promise<any> {
|
|
829
|
-
if (!this.connected) throw new Error('Not connected to Unreal Engine');
|
|
830
|
-
return this.httpCall('/remote/preset', 'GET');
|
|
831
438
|
}
|
|
832
439
|
|
|
833
440
|
// Execute a console command safely with validation and throttling
|
|
834
|
-
async executeConsoleCommand(command: string,
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
if (!command || typeof command !== 'string') {
|
|
841
|
-
throw new Error('Invalid command: must be a non-empty string');
|
|
441
|
+
async executeConsoleCommand(command: string, _options: Record<string, never> = {}): Promise<any> {
|
|
442
|
+
const automationAvailable = Boolean(
|
|
443
|
+
this.automationBridge && typeof this.automationBridge.sendAutomationRequest === 'function'
|
|
444
|
+
);
|
|
445
|
+
if (!automationAvailable) {
|
|
446
|
+
throw new Error('Automation bridge not connected');
|
|
842
447
|
}
|
|
843
|
-
|
|
448
|
+
|
|
449
|
+
// Validate command
|
|
450
|
+
CommandValidator.validate(command);
|
|
844
451
|
const cmdTrimmed = command.trim();
|
|
845
452
|
if (cmdTrimmed.length === 0) {
|
|
846
|
-
// Return success for empty commands to match UE behavior
|
|
847
453
|
return { success: true, message: 'Empty command ignored' };
|
|
848
454
|
}
|
|
849
455
|
|
|
850
|
-
if (
|
|
851
|
-
|
|
456
|
+
if (CommandValidator.isLikelyInvalid(cmdTrimmed)) {
|
|
457
|
+
this.log.warn(`Command appears invalid: ${cmdTrimmed}`);
|
|
852
458
|
}
|
|
853
459
|
|
|
854
|
-
const
|
|
460
|
+
const priority = CommandValidator.getPriority(cmdTrimmed);
|
|
855
461
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const dangerousCommands = [
|
|
862
|
-
'quit', 'exit', 'delete', 'destroy', 'kill', 'crash',
|
|
863
|
-
'viewmode visualizebuffer basecolor',
|
|
864
|
-
'viewmode visualizebuffer worldnormal',
|
|
865
|
-
'r.gpucrash',
|
|
866
|
-
'buildpaths', // Can cause access violation if nav system not initialized
|
|
867
|
-
'rebuildnavigation' // Can also crash without proper nav setup
|
|
868
|
-
];
|
|
869
|
-
if (dangerousCommands.some(dangerous => cmdLower.includes(dangerous))) {
|
|
870
|
-
throw new Error(`Dangerous command blocked: ${command}`);
|
|
871
|
-
}
|
|
462
|
+
const executeCommand = async (): Promise<any> => {
|
|
463
|
+
if (process.env.MOCK_UNREAL_CONNECTION === 'true') {
|
|
464
|
+
this.log.info(`[MOCK] Executing console command: ${cmdTrimmed}`);
|
|
465
|
+
return { success: true, message: `Mock execution of '${cmdTrimmed}' successful`, transport: 'mock_bridge' };
|
|
466
|
+
}
|
|
872
467
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
'import os', 'import subprocess', 'subprocess.', 'os.system',
|
|
877
|
-
'exec(', 'eval(', '__import__', 'import sys', 'import importlib',
|
|
878
|
-
'with open', 'open('
|
|
879
|
-
];
|
|
468
|
+
if (!this.automationBridge || !this.automationBridge.isConnected()) {
|
|
469
|
+
throw new Error('Automation bridge not connected');
|
|
470
|
+
}
|
|
880
471
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
472
|
+
const pluginResp: any = await this.automationBridge.sendAutomationRequest(
|
|
473
|
+
'console_command',
|
|
474
|
+
{ command: cmdTrimmed },
|
|
475
|
+
{ timeoutMs: 30000 }
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
if (pluginResp && pluginResp.success) {
|
|
479
|
+
return { ...(pluginResp as any), transport: 'automation_bridge' };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const errMsg = pluginResp?.message || pluginResp?.error || 'Plugin execution failed';
|
|
483
|
+
throw new Error(errMsg);
|
|
484
|
+
};
|
|
884
485
|
|
|
885
|
-
if (forbiddenTokens.some(token => cmdLower.includes(token))) {
|
|
886
|
-
throw new Error(`Command contains unsafe token and was blocked: ${command}`);
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// Determine priority based on command type
|
|
890
|
-
let priority = 7; // Default priority
|
|
891
|
-
|
|
892
|
-
if (command.includes('BuildLighting') || command.includes('BuildPaths')) {
|
|
893
|
-
priority = 1; // Heavy operation
|
|
894
|
-
} else if (command.includes('summon') || command.includes('spawn')) {
|
|
895
|
-
priority = 5; // Medium operation
|
|
896
|
-
} else if (command.startsWith('stat') || command.startsWith('show')) {
|
|
897
|
-
priority = 9; // Light operation
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Known invalid command patterns
|
|
901
|
-
const invalidPatterns = [
|
|
902
|
-
/^\d+$/, // Just numbers
|
|
903
|
-
/^invalid_command/i,
|
|
904
|
-
/^this_is_not_a_valid/i,
|
|
905
|
-
];
|
|
906
|
-
|
|
907
|
-
const isLikelyInvalid = invalidPatterns.some(pattern => pattern.test(cmdTrimmed));
|
|
908
|
-
if (isLikelyInvalid) {
|
|
909
|
-
this.log.warn(`Command appears invalid: ${cmdTrimmed}`);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
486
|
try {
|
|
913
|
-
const result = await this.executeThrottledCommand(
|
|
914
|
-
() => this.httpCall('/remote/object/call', 'PUT', {
|
|
915
|
-
objectPath: '/Script/Engine.Default__KismetSystemLibrary',
|
|
916
|
-
functionName: 'ExecuteConsoleCommand',
|
|
917
|
-
parameters: {
|
|
918
|
-
WorldContextObject: null,
|
|
919
|
-
Command: cmdTrimmed,
|
|
920
|
-
SpecificPlayer: null
|
|
921
|
-
},
|
|
922
|
-
generateTransaction: false
|
|
923
|
-
}),
|
|
924
|
-
priority
|
|
925
|
-
);
|
|
926
|
-
|
|
487
|
+
const result = await this.executeThrottledCommand(executeCommand, priority);
|
|
927
488
|
return result;
|
|
928
489
|
} catch (error) {
|
|
929
490
|
this.log.error(`Console command failed: ${cmdTrimmed}`, error);
|
|
@@ -931,50 +492,8 @@ print('RESULT:' + json.dumps(status))
|
|
|
931
492
|
}
|
|
932
493
|
}
|
|
933
494
|
|
|
934
|
-
summarizeConsoleCommand(command: string, response: any) {
|
|
935
|
-
const trimmedCommand = command.trim();
|
|
936
|
-
const logLines = Array.isArray(response?.LogOutput)
|
|
937
|
-
? (response.LogOutput as any[]).map(entry => {
|
|
938
|
-
if (entry === null || entry === undefined) {
|
|
939
|
-
return '';
|
|
940
|
-
}
|
|
941
|
-
if (typeof entry === 'string') {
|
|
942
|
-
return entry;
|
|
943
|
-
}
|
|
944
|
-
return typeof entry.Output === 'string' ? entry.Output : '';
|
|
945
|
-
}).filter(Boolean)
|
|
946
|
-
: [];
|
|
947
|
-
|
|
948
|
-
let output = logLines.join('\n').trim();
|
|
949
|
-
if (!output) {
|
|
950
|
-
if (typeof response === 'string') {
|
|
951
|
-
output = response.trim();
|
|
952
|
-
} else if (response && typeof response === 'object') {
|
|
953
|
-
if (typeof response.Output === 'string') {
|
|
954
|
-
output = response.Output.trim();
|
|
955
|
-
} else if ('result' in response && response.result !== undefined) {
|
|
956
|
-
output = String(response.result).trim();
|
|
957
|
-
} else if ('ReturnValue' in response && typeof response.ReturnValue === 'string') {
|
|
958
|
-
output = response.ReturnValue.trim();
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
const returnValue = response && typeof response === 'object' && 'ReturnValue' in response
|
|
964
|
-
? (response as any).ReturnValue
|
|
965
|
-
: undefined;
|
|
966
|
-
|
|
967
|
-
return {
|
|
968
|
-
command: trimmedCommand,
|
|
969
|
-
output,
|
|
970
|
-
logLines,
|
|
971
|
-
returnValue,
|
|
972
|
-
raw: response
|
|
973
|
-
};
|
|
974
|
-
}
|
|
975
|
-
|
|
976
495
|
async executeConsoleCommands(
|
|
977
|
-
commands: Iterable<string | { command: string; priority?: number
|
|
496
|
+
commands: Iterable<string | { command: string; priority?: number }>,
|
|
978
497
|
options: { continueOnError?: boolean; delayMs?: number } = {}
|
|
979
498
|
): Promise<any[]> {
|
|
980
499
|
const { continueOnError = false, delayMs = 0 } = options;
|
|
@@ -987,9 +506,7 @@ print('RESULT:' + json.dumps(status))
|
|
|
987
506
|
continue;
|
|
988
507
|
}
|
|
989
508
|
try {
|
|
990
|
-
const result = await this.executeConsoleCommand(command
|
|
991
|
-
allowPython: Boolean(descriptor.allowPython)
|
|
992
|
-
});
|
|
509
|
+
const result = await this.executeConsoleCommand(command);
|
|
993
510
|
results.push(result);
|
|
994
511
|
} catch (error) {
|
|
995
512
|
if (!continueOnError) {
|
|
@@ -1007,482 +524,106 @@ print('RESULT:' + json.dumps(status))
|
|
|
1007
524
|
return results;
|
|
1008
525
|
}
|
|
1009
526
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
527
|
+
async executeEditorFunction(
|
|
528
|
+
functionName: string,
|
|
529
|
+
params?: Record<string, any>,
|
|
530
|
+
_options?: { timeoutMs?: number }
|
|
531
|
+
): Promise<any> {
|
|
532
|
+
if (process.env.MOCK_UNREAL_CONNECTION === 'true') {
|
|
533
|
+
return { success: true, result: { status: 'mock_success', function: functionName } };
|
|
1014
534
|
}
|
|
1015
|
-
const isMultiLine = /[\r\n]/.test(command) || command.includes(';');
|
|
1016
|
-
try {
|
|
1017
|
-
// Use ExecutePythonCommandEx with appropriate mode based on content
|
|
1018
|
-
return await this.httpCall('/remote/object/call', 'PUT', {
|
|
1019
|
-
objectPath: '/Script/PythonScriptPlugin.Default__PythonScriptLibrary',
|
|
1020
|
-
functionName: 'ExecutePythonCommandEx',
|
|
1021
|
-
parameters: {
|
|
1022
|
-
PythonCommand: command,
|
|
1023
|
-
ExecutionMode: isMultiLine ? 'ExecuteFile' : 'ExecuteStatement',
|
|
1024
|
-
FileExecutionScope: 'Private'
|
|
1025
|
-
},
|
|
1026
|
-
generateTransaction: false
|
|
1027
|
-
});
|
|
1028
|
-
} catch {
|
|
1029
|
-
try {
|
|
1030
|
-
// Fallback to ExecutePythonCommand (more tolerant for multi-line)
|
|
1031
|
-
return await this.httpCall('/remote/object/call', 'PUT', {
|
|
1032
|
-
objectPath: '/Script/PythonScriptPlugin.Default__PythonScriptLibrary',
|
|
1033
|
-
functionName: 'ExecutePythonCommand',
|
|
1034
|
-
parameters: {
|
|
1035
|
-
Command: command
|
|
1036
|
-
},
|
|
1037
|
-
generateTransaction: false
|
|
1038
|
-
});
|
|
1039
|
-
} catch {
|
|
1040
|
-
// Final fallback: execute via console py command
|
|
1041
|
-
this.log.warn('PythonScriptLibrary not available or failed, falling back to console `py` command');
|
|
1042
|
-
|
|
1043
|
-
// For simple single-line commands
|
|
1044
|
-
if (!isMultiLine) {
|
|
1045
|
-
return await this.executeConsoleCommand(`py ${command}`, { allowPython: true });
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// For multi-line scripts, try to execute as a block
|
|
1049
|
-
try {
|
|
1050
|
-
// Try executing as a single exec block
|
|
1051
|
-
// Properly escape the script for Python exec
|
|
1052
|
-
const escapedScript = command
|
|
1053
|
-
.replace(/\\/g, '\\\\')
|
|
1054
|
-
.replace(/"/g, '\\"')
|
|
1055
|
-
.replace(/\n/g, '\\n')
|
|
1056
|
-
.replace(/\r/g, '');
|
|
1057
|
-
return await this.executeConsoleCommand(`py exec("${escapedScript}")`, { allowPython: true });
|
|
1058
|
-
} catch {
|
|
1059
|
-
// If that fails, break into smaller chunks
|
|
1060
|
-
try {
|
|
1061
|
-
// First ensure unreal is imported
|
|
1062
|
-
await this.executeConsoleCommand('py import unreal');
|
|
1063
|
-
|
|
1064
|
-
// For complex multi-line scripts, execute in logical chunks
|
|
1065
|
-
const commandWithoutImport = command.replace(/^\s*import\s+unreal\s*;?\s*/m, '');
|
|
1066
|
-
|
|
1067
|
-
// Split by semicolons first, then by newlines
|
|
1068
|
-
const statements = commandWithoutImport
|
|
1069
|
-
.split(/[;\n]/)
|
|
1070
|
-
.map(s => s.trim())
|
|
1071
|
-
.filter(s => s.length > 0 && !s.startsWith('#'));
|
|
1072
|
-
|
|
1073
|
-
let result = null;
|
|
1074
|
-
for (const stmt of statements) {
|
|
1075
|
-
// Skip if statement is too long for console
|
|
1076
|
-
if (stmt.length > 200) {
|
|
1077
|
-
// Try to execute as a single exec block
|
|
1078
|
-
const miniScript = `exec("""${stmt.replace(/"/g, '\\"')}""")`;
|
|
1079
|
-
result = await this.executeConsoleCommand(`py ${miniScript}`, { allowPython: true });
|
|
1080
|
-
} else {
|
|
1081
|
-
result = await this.executeConsoleCommand(`py ${stmt}`, { allowPython: true });
|
|
1082
|
-
}
|
|
1083
|
-
// Small delay between commands
|
|
1084
|
-
await new Promise(resolve => setTimeout(resolve, 30));
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
return result;
|
|
1088
|
-
} catch {
|
|
1089
|
-
// Final fallback: execute line by line
|
|
1090
|
-
const lines = command.split('\n').filter(line => line.trim().length > 0);
|
|
1091
|
-
let result = null;
|
|
1092
|
-
|
|
1093
|
-
for (const line of lines) {
|
|
1094
|
-
// Skip comments
|
|
1095
|
-
if (line.trim().startsWith('#')) {
|
|
1096
|
-
continue;
|
|
1097
|
-
}
|
|
1098
|
-
result = await this.executeConsoleCommand(`py ${line.trim()}`, { allowPython: true });
|
|
1099
|
-
// Small delay between commands to ensure execution order
|
|
1100
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
return result;
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// Allow callers to enable/disable auto-reconnect behavior
|
|
1111
|
-
setAutoReconnectEnabled(enabled: boolean): void {
|
|
1112
|
-
this.autoReconnectEnabled = enabled;
|
|
1113
|
-
}
|
|
1114
535
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
if (!this.autoReconnectEnabled) {
|
|
1118
|
-
this.log.info('Auto-reconnect disabled; not scheduling reconnection');
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
if (this.reconnectTimer || this.connected) {
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
|
1126
|
-
this.log.error('Max reconnection attempts reached. Please check Unreal Engine.');
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// Exponential backoff with jitter
|
|
1131
|
-
const delay = Math.min(
|
|
1132
|
-
this.BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
|
|
1133
|
-
30000 // Max 30 seconds
|
|
1134
|
-
);
|
|
1135
|
-
|
|
1136
|
-
this.log.debug(`Scheduling reconnection attempt ${this.reconnectAttempts + 1}/${this.MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
|
|
1137
|
-
|
|
1138
|
-
this.reconnectTimer = setTimeout(async () => {
|
|
1139
|
-
this.reconnectTimer = undefined;
|
|
1140
|
-
this.reconnectAttempts++;
|
|
1141
|
-
|
|
1142
|
-
try {
|
|
1143
|
-
await this.connect();
|
|
1144
|
-
this.reconnectAttempts = 0;
|
|
1145
|
-
this.log.info('Successfully reconnected to Unreal Engine');
|
|
1146
|
-
} catch (err) {
|
|
1147
|
-
this.log.warn('Reconnection attempt failed:', err);
|
|
1148
|
-
this.scheduleReconnect();
|
|
1149
|
-
}
|
|
1150
|
-
}, delay);
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
// Graceful shutdown
|
|
1154
|
-
async disconnect(): Promise<void> {
|
|
1155
|
-
if (this.reconnectTimer) {
|
|
1156
|
-
clearTimeout(this.reconnectTimer);
|
|
1157
|
-
this.reconnectTimer = undefined;
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
if (this.ws) {
|
|
1161
|
-
try {
|
|
1162
|
-
// Avoid unhandled error during shutdown
|
|
1163
|
-
this.ws.on('error', () => {});
|
|
1164
|
-
try { this.ws.close(); } catch {}
|
|
1165
|
-
try { this.ws.terminate(); } catch {}
|
|
1166
|
-
} finally {
|
|
1167
|
-
try { this.ws.removeAllListeners(); } catch {}
|
|
1168
|
-
this.ws = undefined;
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
this.connected = false;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* Enhanced Editor Function Access
|
|
1177
|
-
* Use Python scripting as a bridge to access modern Editor Subsystem functions
|
|
1178
|
-
*/
|
|
1179
|
-
async executeEditorFunction(functionName: string, params?: Record<string, any>): Promise<any> {
|
|
1180
|
-
const template = this.PYTHON_TEMPLATES[functionName];
|
|
1181
|
-
if (!template) {
|
|
1182
|
-
throw new Error(`Unknown editor function: ${functionName}`);
|
|
536
|
+
if (!this.automationBridge || typeof this.automationBridge.sendAutomationRequest !== 'function') {
|
|
537
|
+
return { success: false, error: 'AUTOMATION_BRIDGE_UNAVAILABLE' };
|
|
1183
538
|
}
|
|
1184
539
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
for (const [key, value] of Object.entries(params)) {
|
|
1190
|
-
const placeholder = `{${key}}`;
|
|
1191
|
-
script = script.replace(new RegExp(placeholder, 'g'), String(value));
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
540
|
+
const resp: any = await this.automationBridge.sendAutomationRequest('execute_editor_function', {
|
|
541
|
+
functionName,
|
|
542
|
+
params: params ?? {}
|
|
543
|
+
}, _options?.timeoutMs ? { timeoutMs: _options.timeoutMs } : undefined);
|
|
1194
544
|
|
|
1195
|
-
|
|
1196
|
-
// Execute Python script with result parsing
|
|
1197
|
-
const result = await this.executePythonWithResult(script);
|
|
1198
|
-
return result;
|
|
1199
|
-
} catch (error) {
|
|
1200
|
-
this.log.error(`Failed to execute editor function ${functionName}:`, error);
|
|
1201
|
-
|
|
1202
|
-
// Fallback to console command if Python fails
|
|
1203
|
-
return this.executeFallbackCommand(functionName, params);
|
|
1204
|
-
}
|
|
545
|
+
return resp && resp.success !== false ? (resp.result ?? resp) : resp;
|
|
1205
546
|
}
|
|
1206
547
|
|
|
1207
|
-
/**
|
|
1208
|
-
* Execute Python script and parse the result
|
|
1209
|
-
*/
|
|
1210
|
-
// Expose for internal consumers (resources) that want parsed RESULT blocks
|
|
1211
|
-
public async executePythonWithResult(script: string): Promise<any> {
|
|
1212
|
-
try {
|
|
1213
|
-
// Wrap script to capture output so we can parse RESULT: lines reliably
|
|
1214
|
-
const wrappedScript = `
|
|
1215
|
-
import sys
|
|
1216
|
-
import io
|
|
1217
|
-
old_stdout = sys.stdout
|
|
1218
|
-
sys.stdout = buffer = io.StringIO()
|
|
1219
|
-
try:
|
|
1220
|
-
${script.split('\n').join('\n ')}
|
|
1221
|
-
finally:
|
|
1222
|
-
output = buffer.getvalue()
|
|
1223
|
-
sys.stdout = old_stdout
|
|
1224
|
-
if output:
|
|
1225
|
-
print(output)
|
|
1226
|
-
`.trim()
|
|
1227
|
-
.replace(/\r?\n/g, '\n');
|
|
1228
|
-
|
|
1229
|
-
const response = await this.executePython(wrappedScript);
|
|
1230
|
-
|
|
1231
|
-
// Extract textual output from various response shapes
|
|
1232
|
-
let out = '';
|
|
1233
|
-
try {
|
|
1234
|
-
if (response && typeof response === 'string') {
|
|
1235
|
-
out = response;
|
|
1236
|
-
} else if (response && typeof response === 'object') {
|
|
1237
|
-
if (Array.isArray((response as any).LogOutput)) {
|
|
1238
|
-
out = (response as any).LogOutput.map((l: any) => l.Output || '').join('');
|
|
1239
|
-
} else if (typeof (response as any).Output === 'string') {
|
|
1240
|
-
out = (response as any).Output;
|
|
1241
|
-
} else if (typeof (response as any).result === 'string') {
|
|
1242
|
-
out = (response as any).result;
|
|
1243
|
-
} else {
|
|
1244
|
-
out = JSON.stringify(response);
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
} catch {
|
|
1248
|
-
out = String(response || '');
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// Robust RESULT parsing with bracket matching (handles nested objects)
|
|
1252
|
-
const marker = 'RESULT:';
|
|
1253
|
-
const idx = out.lastIndexOf(marker);
|
|
1254
|
-
if (idx !== -1) {
|
|
1255
|
-
// Find first '{' after the marker
|
|
1256
|
-
let i = idx + marker.length;
|
|
1257
|
-
while (i < out.length && out[i] !== '{') i++;
|
|
1258
|
-
if (i < out.length && out[i] === '{') {
|
|
1259
|
-
let depth = 0;
|
|
1260
|
-
let inStr = false;
|
|
1261
|
-
let esc = false;
|
|
1262
|
-
let j = i;
|
|
1263
|
-
for (; j < out.length; j++) {
|
|
1264
|
-
const ch = out[j];
|
|
1265
|
-
if (esc) { esc = false; continue; }
|
|
1266
|
-
if (ch === '\\') { esc = true; continue; }
|
|
1267
|
-
if (ch === '"') { inStr = !inStr; continue; }
|
|
1268
|
-
if (!inStr) {
|
|
1269
|
-
if (ch === '{') depth++;
|
|
1270
|
-
else if (ch === '}') { depth--; if (depth === 0) { j++; break; } }
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
const jsonStr = out.slice(i, j);
|
|
1274
|
-
try { return JSON.parse(jsonStr); } catch {}
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// Fallback to previous regex approach (best-effort)
|
|
1279
|
-
const matches = Array.from(out.matchAll(/RESULT:({[\s\S]*})/g));
|
|
1280
|
-
if (matches.length > 0) {
|
|
1281
|
-
const last = matches[matches.length - 1][1];
|
|
1282
|
-
try { return JSON.parse(last); } catch { return { raw: last }; }
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
// If no RESULT: marker, return the best-effort textual output or original response
|
|
1286
|
-
return typeof response !== 'undefined' ? response : out;
|
|
1287
|
-
} catch {
|
|
1288
|
-
this.log.warn('Python execution failed, trying direct execution');
|
|
1289
|
-
return this.executePython(script);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/**
|
|
1294
|
-
* Get the Unreal Engine version via Python and parse major/minor/patch.
|
|
1295
|
-
*/
|
|
548
|
+
/** Get Unreal Engine version */
|
|
1296
549
|
async getEngineVersion(): Promise<{ version: string; major: number; minor: number; patch: number; isUE56OrAbove: boolean; }> {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
return this.engineVersionCache.value;
|
|
550
|
+
if (process.env.MOCK_UNREAL_CONNECTION === 'true') {
|
|
551
|
+
return { version: '5.6.0-Mock', major: 5, minor: 6, patch: 0, isUE56OrAbove: true };
|
|
1300
552
|
}
|
|
1301
553
|
|
|
554
|
+
const bridge = this.getAutomationBridge();
|
|
1302
555
|
try {
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
const
|
|
1313
|
-
const
|
|
1314
|
-
const
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
return value;
|
|
556
|
+
const resp: any = await bridge.sendAutomationRequest(
|
|
557
|
+
'system_control',
|
|
558
|
+
{ action: 'get_engine_version' },
|
|
559
|
+
{ timeoutMs: 15000 }
|
|
560
|
+
);
|
|
561
|
+
const raw = resp && typeof resp.result === 'object'
|
|
562
|
+
? (resp.result as any)
|
|
563
|
+
: (resp?.result ?? resp ?? {});
|
|
564
|
+
const version = typeof raw.version === 'string' ? raw.version : 'unknown';
|
|
565
|
+
const major = typeof raw.major === 'number' ? raw.major : 0;
|
|
566
|
+
const minor = typeof raw.minor === 'number' ? raw.minor : 0;
|
|
567
|
+
const patch = typeof raw.patch === 'number' ? raw.patch : 0;
|
|
568
|
+
const isUE56OrAbove =
|
|
569
|
+
typeof raw.isUE56OrAbove === 'boolean'
|
|
570
|
+
? raw.isUE56OrAbove
|
|
571
|
+
: (major > 5 || (major === 5 && minor >= 6));
|
|
572
|
+
return { version, major, minor, patch, isUE56OrAbove };
|
|
1321
573
|
} catch (error) {
|
|
1322
|
-
this.log.warn('
|
|
1323
|
-
const fallback = { version: 'unknown', major: 0, minor: 0, patch: 0, isUE56OrAbove: false };
|
|
1324
|
-
this.engineVersionCache = { value: fallback, timestamp: now };
|
|
1325
|
-
return fallback;
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
/**
|
|
1330
|
-
* Query feature flags (Python availability, editor subsystems) via Python.
|
|
1331
|
-
*/
|
|
1332
|
-
async getFeatureFlags(): Promise<{ pythonEnabled: boolean; subsystems: { unrealEditor: boolean; levelEditor: boolean; editorActor: boolean; } }> {
|
|
1333
|
-
try {
|
|
1334
|
-
const script = `
|
|
1335
|
-
import unreal, json
|
|
1336
|
-
flags = {}
|
|
1337
|
-
# Python plugin availability (class exists)
|
|
1338
|
-
try:
|
|
1339
|
-
_ = unreal.PythonScriptLibrary
|
|
1340
|
-
flags['pythonEnabled'] = True
|
|
1341
|
-
except Exception:
|
|
1342
|
-
flags['pythonEnabled'] = False
|
|
1343
|
-
# Editor subsystems
|
|
1344
|
-
try:
|
|
1345
|
-
flags['unrealEditor'] = bool(unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem))
|
|
1346
|
-
except Exception:
|
|
1347
|
-
flags['unrealEditor'] = False
|
|
1348
|
-
try:
|
|
1349
|
-
flags['levelEditor'] = bool(unreal.get_editor_subsystem(unreal.LevelEditorSubsystem))
|
|
1350
|
-
except Exception:
|
|
1351
|
-
flags['levelEditor'] = False
|
|
1352
|
-
try:
|
|
1353
|
-
flags['editorActor'] = bool(unreal.get_editor_subsystem(unreal.EditorActorSubsystem))
|
|
1354
|
-
except Exception:
|
|
1355
|
-
flags['editorActor'] = False
|
|
1356
|
-
print('RESULT:' + json.dumps(flags))
|
|
1357
|
-
`.trim();
|
|
1358
|
-
const res = await this.executePythonWithResult(script);
|
|
574
|
+
this.log.warn('getEngineVersion failed', error);
|
|
1359
575
|
return {
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
}
|
|
576
|
+
version: 'unknown',
|
|
577
|
+
major: 0,
|
|
578
|
+
minor: 0,
|
|
579
|
+
patch: 0,
|
|
580
|
+
isUE56OrAbove: false
|
|
1366
581
|
};
|
|
1367
|
-
} catch (e) {
|
|
1368
|
-
this.log.warn('Failed to get feature flags via Python', e);
|
|
1369
|
-
return { pythonEnabled: false, subsystems: { unrealEditor: false, levelEditor: false, editorActor: false } };
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
/**
|
|
1374
|
-
* Fallback commands when Python is not available
|
|
1375
|
-
*/
|
|
1376
|
-
private async executeFallbackCommand(functionName: string, params?: Record<string, any>): Promise<any> {
|
|
1377
|
-
switch (functionName) {
|
|
1378
|
-
case 'SPAWN_ACTOR_AT_LOCATION':
|
|
1379
|
-
return this.executeConsoleCommand(
|
|
1380
|
-
`summon ${params?.class_path || 'StaticMeshActor'} ${params?.x || 0} ${params?.y || 0} ${params?.z || 0}`
|
|
1381
|
-
);
|
|
1382
|
-
|
|
1383
|
-
case 'DELETE_ACTOR':
|
|
1384
|
-
// Use Python-based deletion to avoid unsafe console command and improve reliability
|
|
1385
|
-
return this.executePythonWithResult(this.PYTHON_TEMPLATES.DELETE_ACTOR.script.replace('{actor_name}', String(params?.actor_name || '')));
|
|
1386
|
-
|
|
1387
|
-
case 'BUILD_LIGHTING':
|
|
1388
|
-
return this.executeConsoleCommand('BuildLighting');
|
|
1389
|
-
|
|
1390
|
-
default:
|
|
1391
|
-
throw new Error(`No fallback available for ${functionName}`);
|
|
1392
582
|
}
|
|
1393
583
|
}
|
|
1394
584
|
|
|
1395
|
-
/**
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
*/
|
|
1399
|
-
async setSafeViewMode(mode: string): Promise<any> {
|
|
1400
|
-
const acceptedModes = Array.from(new Set(this.VIEWMODE_ALIASES.values())).sort();
|
|
1401
|
-
|
|
1402
|
-
if (typeof mode !== 'string') {
|
|
1403
|
-
return {
|
|
1404
|
-
success: false,
|
|
1405
|
-
error: 'View mode must be provided as a string',
|
|
1406
|
-
acceptedModes
|
|
1407
|
-
};
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
const key = mode.trim().toLowerCase().replace(/[\s_-]+/g, '');
|
|
1411
|
-
if (!key) {
|
|
585
|
+
/** Query feature flags */
|
|
586
|
+
async getFeatureFlags(): Promise<{ subsystems: { unrealEditor: boolean; levelEditor: boolean; editorActor: boolean; } }> {
|
|
587
|
+
if (process.env.MOCK_UNREAL_CONNECTION === 'true') {
|
|
1412
588
|
return {
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
589
|
+
subsystems: {
|
|
590
|
+
unrealEditor: true,
|
|
591
|
+
levelEditor: true,
|
|
592
|
+
editorActor: true
|
|
593
|
+
}
|
|
1416
594
|
};
|
|
1417
595
|
}
|
|
1418
596
|
|
|
1419
|
-
const
|
|
1420
|
-
|
|
597
|
+
const bridge = this.getAutomationBridge();
|
|
598
|
+
try {
|
|
599
|
+
const resp: any = await bridge.sendAutomationRequest(
|
|
600
|
+
'system_control',
|
|
601
|
+
{ action: 'get_feature_flags' },
|
|
602
|
+
{ timeoutMs: 15000 }
|
|
603
|
+
);
|
|
604
|
+
const raw = resp && typeof resp.result === 'object'
|
|
605
|
+
? (resp.result as any)
|
|
606
|
+
: (resp?.result ?? resp ?? {});
|
|
607
|
+
const subs = raw && typeof raw.subsystems === 'object'
|
|
608
|
+
? (raw.subsystems as any)
|
|
609
|
+
: {};
|
|
1421
610
|
return {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
611
|
+
subsystems: {
|
|
612
|
+
unrealEditor: Boolean(subs.unrealEditor),
|
|
613
|
+
levelEditor: Boolean(subs.levelEditor),
|
|
614
|
+
editorActor: Boolean(subs.editorActor)
|
|
615
|
+
}
|
|
1425
616
|
};
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
if (this.HARD_BLOCKED_VIEWMODES.has(targetMode)) {
|
|
1429
|
-
this.log.warn(`Viewmode '${targetMode}' is blocked for safety. Using alternative.`);
|
|
1430
|
-
const alternative = this.getSafeAlternative(targetMode);
|
|
1431
|
-
const altCommand = `viewmode ${alternative}`;
|
|
1432
|
-
const altResult = await this.executeConsoleCommand(altCommand);
|
|
1433
|
-
const altSummary = this.summarizeConsoleCommand(altCommand, altResult);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
this.log.warn('getFeatureFlags failed', error);
|
|
1434
619
|
return {
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
alternative
|
|
620
|
+
subsystems: {
|
|
621
|
+
unrealEditor: false,
|
|
622
|
+
levelEditor: false,
|
|
623
|
+
editorActor: false
|
|
624
|
+
}
|
|
1441
625
|
};
|
|
1442
626
|
}
|
|
1443
|
-
|
|
1444
|
-
const command = `viewmode ${targetMode}`;
|
|
1445
|
-
const rawResult = await this.executeConsoleCommand(command);
|
|
1446
|
-
const summary = this.summarizeConsoleCommand(command, rawResult);
|
|
1447
|
-
const response: any = {
|
|
1448
|
-
...summary,
|
|
1449
|
-
success: summary.returnValue !== false,
|
|
1450
|
-
requestedMode: targetMode,
|
|
1451
|
-
viewMode: targetMode,
|
|
1452
|
-
message: `View mode set to ${targetMode}`
|
|
1453
|
-
};
|
|
1454
|
-
|
|
1455
|
-
if (this.UNSAFE_VIEWMODES.includes(targetMode)) {
|
|
1456
|
-
response.warning = `View mode '${targetMode}' may be unstable on some engine versions.`;
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
if (summary.output && /unknown|invalid/i.test(summary.output)) {
|
|
1460
|
-
response.success = false;
|
|
1461
|
-
response.error = summary.output;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
return response;
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
/**
|
|
1468
|
-
* Get safe alternative for unsafe viewmodes
|
|
1469
|
-
*/
|
|
1470
|
-
private getSafeAlternative(unsafeMode: string): string {
|
|
1471
|
-
const alternatives: Record<string, string> = {
|
|
1472
|
-
'BaseColor': 'Unlit',
|
|
1473
|
-
'WorldNormal': 'Lit',
|
|
1474
|
-
'Metallic': 'Lit',
|
|
1475
|
-
'Specular': 'Lit',
|
|
1476
|
-
'Roughness': 'Lit',
|
|
1477
|
-
'SubsurfaceColor': 'Lit',
|
|
1478
|
-
'Opacity': 'Lit',
|
|
1479
|
-
'LightComplexity': 'LightingOnly',
|
|
1480
|
-
'ShaderComplexity': 'Wireframe',
|
|
1481
|
-
'CollisionPawn': 'Wireframe',
|
|
1482
|
-
'CollisionVisibility': 'Wireframe'
|
|
1483
|
-
};
|
|
1484
|
-
|
|
1485
|
-
return alternatives[unsafeMode] || 'Lit';
|
|
1486
627
|
}
|
|
1487
628
|
|
|
1488
629
|
/**
|
|
@@ -1490,182 +631,10 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1490
631
|
* Prevent rapid command execution that can overwhelm the engine
|
|
1491
632
|
*/
|
|
1492
633
|
private async executeThrottledCommand<T>(
|
|
1493
|
-
command: () => Promise<T>,
|
|
634
|
+
command: () => Promise<T>,
|
|
1494
635
|
priority: number = 5
|
|
1495
636
|
): Promise<T> {
|
|
1496
|
-
return
|
|
1497
|
-
this.commandQueue.push({
|
|
1498
|
-
command,
|
|
1499
|
-
resolve,
|
|
1500
|
-
reject,
|
|
1501
|
-
priority
|
|
1502
|
-
});
|
|
1503
|
-
|
|
1504
|
-
// Sort by priority (lower number = higher priority)
|
|
1505
|
-
this.commandQueue.sort((a, b) => a.priority - b.priority);
|
|
1506
|
-
|
|
1507
|
-
// Process queue if not already processing
|
|
1508
|
-
if (!this.isProcessing) {
|
|
1509
|
-
this.processCommandQueue();
|
|
1510
|
-
}
|
|
1511
|
-
});
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
/**
|
|
1515
|
-
* Process command queue with appropriate delays
|
|
1516
|
-
*/
|
|
1517
|
-
private async processCommandQueue(): Promise<void> {
|
|
1518
|
-
if (this.isProcessing || this.commandQueue.length === 0) {
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
this.isProcessing = true;
|
|
1523
|
-
|
|
1524
|
-
while (this.commandQueue.length > 0) {
|
|
1525
|
-
const item = this.commandQueue.shift();
|
|
1526
|
-
if (!item) continue; // Skip if undefined
|
|
1527
|
-
|
|
1528
|
-
// Calculate delay based on time since last command
|
|
1529
|
-
const timeSinceLastCommand = Date.now() - this.lastCommandTime;
|
|
1530
|
-
const requiredDelay = this.calculateDelay(item.priority);
|
|
1531
|
-
|
|
1532
|
-
if (timeSinceLastCommand < requiredDelay) {
|
|
1533
|
-
await this.delay(requiredDelay - timeSinceLastCommand);
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
try {
|
|
1537
|
-
const result = await item.command();
|
|
1538
|
-
item.resolve(result);
|
|
1539
|
-
} catch (error: any) {
|
|
1540
|
-
// Retry logic for transient failures
|
|
1541
|
-
const msg = (error?.message || String(error)).toLowerCase();
|
|
1542
|
-
const notConnected = msg.includes('not connected to unreal');
|
|
1543
|
-
if (item.retryCount === undefined) {
|
|
1544
|
-
item.retryCount = 0;
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
if (!notConnected && item.retryCount < 3) {
|
|
1548
|
-
item.retryCount++;
|
|
1549
|
-
this.log.warn(`Command failed, retrying (${item.retryCount}/3)`);
|
|
1550
|
-
|
|
1551
|
-
// Re-add to queue with increased priority
|
|
1552
|
-
this.commandQueue.unshift({
|
|
1553
|
-
command: item.command,
|
|
1554
|
-
resolve: item.resolve,
|
|
1555
|
-
reject: item.reject,
|
|
1556
|
-
priority: Math.max(1, item.priority - 1),
|
|
1557
|
-
retryCount: item.retryCount
|
|
1558
|
-
});
|
|
1559
|
-
|
|
1560
|
-
// Add extra delay before retry
|
|
1561
|
-
await this.delay(500);
|
|
1562
|
-
} else {
|
|
1563
|
-
item.reject(error);
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
this.lastCommandTime = Date.now();
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
this.isProcessing = false;
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
/**
|
|
1574
|
-
* Calculate appropriate delay based on command priority and type
|
|
1575
|
-
*/
|
|
1576
|
-
private calculateDelay(priority: number): number {
|
|
1577
|
-
// Priority 1-3: Heavy operations (asset creation, lighting build)
|
|
1578
|
-
if (priority <= 3) {
|
|
1579
|
-
return this.MAX_COMMAND_DELAY;
|
|
1580
|
-
}
|
|
1581
|
-
// Priority 4-6: Medium operations (actor spawning, material changes)
|
|
1582
|
-
else if (priority <= 6) {
|
|
1583
|
-
return 200;
|
|
1584
|
-
}
|
|
1585
|
-
// Priority 8: Stat commands - need special handling
|
|
1586
|
-
else if (priority === 8) {
|
|
1587
|
-
// Check time since last stat command to avoid FindConsoleObject warnings
|
|
1588
|
-
const timeSinceLastStat = Date.now() - this.lastStatCommandTime;
|
|
1589
|
-
if (timeSinceLastStat < this.STAT_COMMAND_DELAY) {
|
|
1590
|
-
return this.STAT_COMMAND_DELAY;
|
|
1591
|
-
}
|
|
1592
|
-
this.lastStatCommandTime = Date.now();
|
|
1593
|
-
return 150;
|
|
1594
|
-
}
|
|
1595
|
-
// Priority 7,9-10: Light operations (console commands, queries)
|
|
1596
|
-
else {
|
|
1597
|
-
// For light operations, add some jitter to prevent thundering herd
|
|
1598
|
-
const baseDelay = this.MIN_COMMAND_DELAY;
|
|
1599
|
-
const jitter = Math.random() * 50; // Add up to 50ms random jitter
|
|
1600
|
-
return baseDelay + jitter;
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
/**
|
|
1605
|
-
* SOLUTION 4: Enhanced Asset Creation
|
|
1606
|
-
* Use Python scripting for complex asset creation that requires editor scripting
|
|
1607
|
-
*/
|
|
1608
|
-
async createComplexAsset(assetType: string, params: Record<string, any>): Promise<any> {
|
|
1609
|
-
const assetCreators: Record<string, string> = {
|
|
1610
|
-
'Material': 'MaterialFactoryNew',
|
|
1611
|
-
'MaterialInstance': 'MaterialInstanceConstantFactoryNew',
|
|
1612
|
-
'Blueprint': 'BlueprintFactory',
|
|
1613
|
-
'AnimationBlueprint': 'AnimBlueprintFactory',
|
|
1614
|
-
'ControlRig': 'ControlRigBlueprintFactory',
|
|
1615
|
-
'NiagaraSystem': 'NiagaraSystemFactoryNew',
|
|
1616
|
-
'NiagaraEmitter': 'NiagaraEmitterFactoryNew',
|
|
1617
|
-
'LandscapeGrassType': 'LandscapeGrassTypeFactory',
|
|
1618
|
-
'PhysicsAsset': 'PhysicsAssetFactory'
|
|
1619
|
-
};
|
|
1620
|
-
|
|
1621
|
-
const factoryClass = assetCreators[assetType];
|
|
1622
|
-
if (!factoryClass) {
|
|
1623
|
-
throw new Error(`Unknown asset type: ${assetType}`);
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
const createParams = {
|
|
1627
|
-
factory_class: factoryClass,
|
|
1628
|
-
asset_class: `unreal.${assetType}`,
|
|
1629
|
-
asset_name: params.name || `New${assetType}`,
|
|
1630
|
-
package_path: params.path || '/Game/CreatedAssets',
|
|
1631
|
-
...params
|
|
1632
|
-
};
|
|
1633
|
-
|
|
1634
|
-
return this.executeEditorFunction('CREATE_ASSET', createParams);
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
/**
|
|
1638
|
-
* Start the command processor
|
|
1639
|
-
*/
|
|
1640
|
-
private startCommandProcessor(): void {
|
|
1641
|
-
// Periodic queue processing to handle any stuck commands
|
|
1642
|
-
setInterval(() => {
|
|
1643
|
-
if (!this.isProcessing && this.commandQueue.length > 0) {
|
|
1644
|
-
this.processCommandQueue();
|
|
1645
|
-
}
|
|
1646
|
-
}, 1000);
|
|
1647
|
-
|
|
1648
|
-
// Clean console cache every 5 minutes
|
|
1649
|
-
setInterval(() => {
|
|
1650
|
-
this.cleanConsoleCache();
|
|
1651
|
-
}, this.CONSOLE_CACHE_TTL);
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
/**
|
|
1655
|
-
* Clean expired entries from console object cache
|
|
1656
|
-
*/
|
|
1657
|
-
private cleanConsoleCache(): void {
|
|
1658
|
-
const now = Date.now();
|
|
1659
|
-
let cleaned = 0;
|
|
1660
|
-
for (const [key, value] of this.consoleObjectCache.entries()) {
|
|
1661
|
-
if (now - (value.timestamp || 0) > this.CONSOLE_CACHE_TTL) {
|
|
1662
|
-
this.consoleObjectCache.delete(key);
|
|
1663
|
-
cleaned++;
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
if (cleaned > 0) {
|
|
1667
|
-
this.log.debug(`Cleaned ${cleaned} expired console cache entries`);
|
|
1668
|
-
}
|
|
637
|
+
return this.commandQueue.execute(command, priority);
|
|
1669
638
|
}
|
|
1670
639
|
|
|
1671
640
|
/**
|
|
@@ -1674,63 +643,4 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1674
643
|
private delay(ms: number): Promise<void> {
|
|
1675
644
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1676
645
|
}
|
|
1677
|
-
|
|
1678
|
-
/**
|
|
1679
|
-
* Batch command execution with proper delays
|
|
1680
|
-
*/
|
|
1681
|
-
async executeBatch(commands: Array<{ command: string; priority?: number }>): Promise<any[]> {
|
|
1682
|
-
return this.executeConsoleCommands(commands.map(cmd => cmd.command));
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
/**
|
|
1686
|
-
* Get safe console commands for common operations
|
|
1687
|
-
*/
|
|
1688
|
-
getSafeCommands(): Record<string, string> {
|
|
1689
|
-
return {
|
|
1690
|
-
// Health check (safe, no side effects)
|
|
1691
|
-
'HealthCheck': 'echo MCP Server Health Check',
|
|
1692
|
-
|
|
1693
|
-
// Performance monitoring (safe)
|
|
1694
|
-
'ShowFPS': 'stat unit', // Use 'stat unit' instead of 'stat fps'
|
|
1695
|
-
'ShowMemory': 'stat memory',
|
|
1696
|
-
'ShowGame': 'stat game',
|
|
1697
|
-
'ShowRendering': 'stat scenerendering',
|
|
1698
|
-
'ClearStats': 'stat none',
|
|
1699
|
-
|
|
1700
|
-
// Safe viewmodes
|
|
1701
|
-
'ViewLit': 'viewmode lit',
|
|
1702
|
-
'ViewUnlit': 'viewmode unlit',
|
|
1703
|
-
'ViewWireframe': 'viewmode wireframe',
|
|
1704
|
-
'ViewDetailLighting': 'viewmode detaillighting',
|
|
1705
|
-
'ViewLightingOnly': 'viewmode lightingonly',
|
|
1706
|
-
|
|
1707
|
-
// Safe show flags
|
|
1708
|
-
'ShowBounds': 'show bounds',
|
|
1709
|
-
'ShowCollision': 'show collision',
|
|
1710
|
-
'ShowNavigation': 'show navigation',
|
|
1711
|
-
'ShowFog': 'show fog',
|
|
1712
|
-
'ShowGrid': 'show grid',
|
|
1713
|
-
|
|
1714
|
-
// PIE controls
|
|
1715
|
-
'PlayInEditor': 'play',
|
|
1716
|
-
'StopPlay': 'stop',
|
|
1717
|
-
'PausePlay': 'pause',
|
|
1718
|
-
|
|
1719
|
-
// Time control
|
|
1720
|
-
'SlowMotion': 'slomo 0.5',
|
|
1721
|
-
'NormalSpeed': 'slomo 1',
|
|
1722
|
-
'FastForward': 'slomo 2',
|
|
1723
|
-
|
|
1724
|
-
// Camera controls
|
|
1725
|
-
'CameraSpeed1': 'camspeed 1',
|
|
1726
|
-
'CameraSpeed4': 'camspeed 4',
|
|
1727
|
-
'CameraSpeed8': 'camspeed 8',
|
|
1728
|
-
|
|
1729
|
-
// Rendering quality (safe)
|
|
1730
|
-
'LowQuality': 'sg.ViewDistanceQuality 0',
|
|
1731
|
-
'MediumQuality': 'sg.ViewDistanceQuality 1',
|
|
1732
|
-
'HighQuality': 'sg.ViewDistanceQuality 2',
|
|
1733
|
-
'EpicQuality': 'sg.ViewDistanceQuality 3'
|
|
1734
|
-
};
|
|
1735
|
-
}
|
|
1736
646
|
}
|