unreal-engine-mcp-server 0.4.0 → 0.4.4
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.production +1 -1
- package/.github/copilot-instructions.md +45 -0
- package/.github/workflows/publish-mcp.yml +3 -2
- package/README.md +21 -5
- package/dist/index.js +124 -31
- package/dist/prompts/index.d.ts +10 -3
- package/dist/prompts/index.js +186 -7
- package/dist/resources/actors.d.ts +19 -1
- package/dist/resources/actors.js +55 -64
- package/dist/resources/assets.js +46 -62
- package/dist/resources/levels.d.ts +21 -3
- package/dist/resources/levels.js +29 -54
- package/dist/tools/actors.d.ts +3 -14
- package/dist/tools/actors.js +246 -302
- package/dist/tools/animation.d.ts +57 -102
- package/dist/tools/animation.js +429 -450
- package/dist/tools/assets.d.ts +13 -2
- package/dist/tools/assets.js +52 -44
- package/dist/tools/audio.d.ts +22 -13
- package/dist/tools/audio.js +467 -121
- package/dist/tools/blueprint.d.ts +32 -13
- package/dist/tools/blueprint.js +699 -448
- package/dist/tools/build_environment_advanced.d.ts +0 -1
- package/dist/tools/build_environment_advanced.js +190 -45
- package/dist/tools/consolidated-tool-definitions.js +78 -252
- package/dist/tools/consolidated-tool-handlers.js +506 -133
- package/dist/tools/debug.d.ts +72 -10
- package/dist/tools/debug.js +167 -31
- package/dist/tools/editor.d.ts +9 -2
- package/dist/tools/editor.js +30 -44
- package/dist/tools/foliage.d.ts +34 -15
- package/dist/tools/foliage.js +97 -107
- package/dist/tools/introspection.js +19 -21
- package/dist/tools/landscape.d.ts +1 -2
- package/dist/tools/landscape.js +311 -168
- package/dist/tools/level.d.ts +3 -28
- package/dist/tools/level.js +642 -192
- package/dist/tools/lighting.d.ts +14 -3
- package/dist/tools/lighting.js +236 -123
- package/dist/tools/materials.d.ts +25 -7
- package/dist/tools/materials.js +102 -79
- package/dist/tools/niagara.d.ts +10 -12
- package/dist/tools/niagara.js +74 -94
- package/dist/tools/performance.d.ts +12 -4
- package/dist/tools/performance.js +38 -79
- package/dist/tools/physics.d.ts +34 -10
- package/dist/tools/physics.js +364 -292
- package/dist/tools/rc.js +97 -23
- package/dist/tools/sequence.d.ts +1 -0
- package/dist/tools/sequence.js +125 -22
- package/dist/tools/ui.d.ts +31 -4
- package/dist/tools/ui.js +83 -66
- package/dist/tools/visual.d.ts +11 -0
- package/dist/tools/visual.js +245 -30
- package/dist/types/tool-types.d.ts +0 -6
- package/dist/types/tool-types.js +1 -8
- package/dist/unreal-bridge.d.ts +32 -2
- package/dist/unreal-bridge.js +621 -127
- package/dist/utils/elicitation.d.ts +57 -0
- package/dist/utils/elicitation.js +104 -0
- package/dist/utils/error-handler.d.ts +0 -33
- package/dist/utils/error-handler.js +4 -111
- package/dist/utils/http.d.ts +2 -22
- package/dist/utils/http.js +12 -75
- package/dist/utils/normalize.d.ts +4 -4
- package/dist/utils/normalize.js +15 -7
- package/dist/utils/python-output.d.ts +18 -0
- package/dist/utils/python-output.js +290 -0
- package/dist/utils/python.d.ts +2 -0
- package/dist/utils/python.js +4 -0
- package/dist/utils/response-validator.js +28 -2
- package/dist/utils/result-helpers.d.ts +27 -0
- package/dist/utils/result-helpers.js +147 -0
- package/dist/utils/safe-json.d.ts +0 -2
- package/dist/utils/safe-json.js +0 -43
- package/dist/utils/validation.d.ts +16 -0
- package/dist/utils/validation.js +70 -7
- package/mcp-config-example.json +2 -2
- package/package.json +10 -9
- package/server.json +37 -14
- package/src/index.ts +130 -33
- package/src/prompts/index.ts +211 -13
- package/src/resources/actors.ts +59 -44
- package/src/resources/assets.ts +48 -51
- package/src/resources/levels.ts +35 -45
- package/src/tools/actors.ts +269 -313
- package/src/tools/animation.ts +556 -539
- package/src/tools/assets.ts +53 -43
- package/src/tools/audio.ts +507 -113
- package/src/tools/blueprint.ts +778 -462
- package/src/tools/build_environment_advanced.ts +266 -64
- package/src/tools/consolidated-tool-definitions.ts +90 -264
- package/src/tools/consolidated-tool-handlers.ts +630 -121
- package/src/tools/debug.ts +176 -33
- package/src/tools/editor.ts +35 -37
- package/src/tools/foliage.ts +110 -104
- package/src/tools/introspection.ts +24 -22
- package/src/tools/landscape.ts +334 -181
- package/src/tools/level.ts +683 -182
- package/src/tools/lighting.ts +244 -123
- package/src/tools/materials.ts +114 -83
- package/src/tools/niagara.ts +87 -81
- package/src/tools/performance.ts +49 -88
- package/src/tools/physics.ts +393 -299
- package/src/tools/rc.ts +102 -24
- package/src/tools/sequence.ts +136 -28
- package/src/tools/ui.ts +101 -70
- package/src/tools/visual.ts +250 -29
- package/src/types/tool-types.ts +0 -9
- package/src/unreal-bridge.ts +658 -140
- package/src/utils/elicitation.ts +129 -0
- package/src/utils/error-handler.ts +4 -159
- package/src/utils/http.ts +16 -115
- package/src/utils/normalize.ts +20 -10
- package/src/utils/python-output.ts +351 -0
- package/src/utils/python.ts +3 -0
- package/src/utils/response-validator.ts +25 -2
- package/src/utils/result-helpers.ts +193 -0
- package/src/utils/safe-json.ts +0 -50
- package/src/utils/validation.ts +94 -7
- package/tests/run-unreal-tool-tests.mjs +720 -0
- package/tsconfig.json +2 -2
- package/dist/python-utils.d.ts +0 -29
- package/dist/python-utils.js +0 -54
- package/dist/types/index.d.ts +0 -323
- package/dist/types/index.js +0 -28
- package/dist/utils/cache-manager.d.ts +0 -64
- package/dist/utils/cache-manager.js +0 -176
- package/dist/utils/errors.d.ts +0 -133
- package/dist/utils/errors.js +0 -256
- package/src/python/editor_compat.py +0 -181
- package/src/python-utils.ts +0 -57
- package/src/types/index.ts +0 -414
- package/src/utils/cache-manager.ts +0 -213
- package/src/utils/errors.ts +0 -312
package/dist/unreal-bridge.js
CHANGED
|
@@ -13,6 +13,8 @@ export class UnrealBridge {
|
|
|
13
13
|
MAX_RECONNECT_ATTEMPTS = 5;
|
|
14
14
|
BASE_RECONNECT_DELAY = 1000;
|
|
15
15
|
autoReconnectEnabled = false; // disabled by default to prevent looping retries
|
|
16
|
+
engineVersionCache;
|
|
17
|
+
ENGINE_VERSION_TTL_MS = 5 * 60 * 1000;
|
|
16
18
|
// Command queue for throttling
|
|
17
19
|
commandQueue = [];
|
|
18
20
|
isProcessing = false;
|
|
@@ -21,45 +23,100 @@ export class UnrealBridge {
|
|
|
21
23
|
STAT_COMMAND_DELAY = 300; // Special delay for stat commands to avoid warnings
|
|
22
24
|
lastCommandTime = 0;
|
|
23
25
|
lastStatCommandTime = 0; // Track stat commands separately
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// Console object cache to reduce FindConsoleObject warnings
|
|
27
|
+
consoleObjectCache = new Map();
|
|
28
|
+
CONSOLE_CACHE_TTL = 300000; // 5 minutes TTL for cached objects
|
|
29
|
+
pluginStatusCache = new Map();
|
|
30
|
+
PLUGIN_CACHE_TTL = 5 * 60 * 1000;
|
|
29
31
|
// Unsafe viewmodes that can cause crashes or instability via visualizeBuffer
|
|
30
32
|
UNSAFE_VIEWMODES = [
|
|
31
33
|
'BaseColor', 'WorldNormal', 'Metallic', 'Specular',
|
|
32
|
-
'Roughness',
|
|
34
|
+
'Roughness',
|
|
35
|
+
'SubsurfaceColor',
|
|
36
|
+
'Opacity',
|
|
33
37
|
'LightComplexity', 'LightmapDensity',
|
|
34
38
|
'StationaryLightOverlap', 'CollisionPawn', 'CollisionVisibility'
|
|
35
39
|
];
|
|
40
|
+
HARD_BLOCKED_VIEWMODES = new Set([
|
|
41
|
+
'BaseColor', 'WorldNormal', 'Metallic', 'Specular', 'Roughness', 'SubsurfaceColor', 'Opacity'
|
|
42
|
+
]);
|
|
43
|
+
VIEWMODE_ALIASES = new Map([
|
|
44
|
+
['lit', 'Lit'],
|
|
45
|
+
['unlit', 'Unlit'],
|
|
46
|
+
['wireframe', 'Wireframe'],
|
|
47
|
+
['brushwireframe', 'BrushWireframe'],
|
|
48
|
+
['brush_wireframe', 'BrushWireframe'],
|
|
49
|
+
['detaillighting', 'DetailLighting'],
|
|
50
|
+
['detail_lighting', 'DetailLighting'],
|
|
51
|
+
['lightingonly', 'LightingOnly'],
|
|
52
|
+
['lighting_only', 'LightingOnly'],
|
|
53
|
+
['lightonly', 'LightingOnly'],
|
|
54
|
+
['light_only', 'LightingOnly'],
|
|
55
|
+
['lightcomplexity', 'LightComplexity'],
|
|
56
|
+
['light_complexity', 'LightComplexity'],
|
|
57
|
+
['shadercomplexity', 'ShaderComplexity'],
|
|
58
|
+
['shader_complexity', 'ShaderComplexity'],
|
|
59
|
+
['lightmapdensity', 'LightmapDensity'],
|
|
60
|
+
['lightmap_density', 'LightmapDensity'],
|
|
61
|
+
['stationarylightoverlap', 'StationaryLightOverlap'],
|
|
62
|
+
['stationary_light_overlap', 'StationaryLightOverlap'],
|
|
63
|
+
['reflectionoverride', 'ReflectionOverride'],
|
|
64
|
+
['reflection_override', 'ReflectionOverride'],
|
|
65
|
+
['texeldensity', 'TexelDensity'],
|
|
66
|
+
['texel_density', 'TexelDensity'],
|
|
67
|
+
['vertexcolor', 'VertexColor'],
|
|
68
|
+
['vertex_color', 'VertexColor'],
|
|
69
|
+
['litdetail', 'DetailLighting'],
|
|
70
|
+
['lit_only', 'LightingOnly']
|
|
71
|
+
]);
|
|
36
72
|
// Python script templates for EditorLevelLibrary access
|
|
37
73
|
PYTHON_TEMPLATES = {
|
|
38
74
|
GET_ALL_ACTORS: {
|
|
39
75
|
name: 'get_all_actors',
|
|
40
76
|
script: `
|
|
41
77
|
import unreal
|
|
78
|
+
import json
|
|
79
|
+
|
|
42
80
|
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
81
|
+
try:
|
|
82
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
83
|
+
if subsys:
|
|
84
|
+
actors = subsys.get_all_level_actors()
|
|
85
|
+
result = [{'name': a.get_name(), 'class': a.get_class().get_name(), 'path': a.get_path_name()} for a in actors]
|
|
86
|
+
print(f"RESULT:{json.dumps(result)}")
|
|
87
|
+
else:
|
|
88
|
+
print("RESULT:[]")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"RESULT:{json.dumps({'error': str(e)})}")
|
|
47
91
|
`.trim()
|
|
48
92
|
},
|
|
49
93
|
SPAWN_ACTOR_AT_LOCATION: {
|
|
50
94
|
name: 'spawn_actor',
|
|
51
95
|
script: `
|
|
52
96
|
import unreal
|
|
97
|
+
import json
|
|
98
|
+
|
|
53
99
|
location = unreal.Vector({x}, {y}, {z})
|
|
54
100
|
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
104
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
105
|
+
if subsys:
|
|
106
|
+
# Try to load asset class
|
|
107
|
+
actor_class = unreal.EditorAssetLibrary.load_asset("{class_path}")
|
|
108
|
+
if actor_class:
|
|
109
|
+
spawned = subsys.spawn_actor_from_object(actor_class, location, rotation)
|
|
110
|
+
if spawned:
|
|
111
|
+
print(f"RESULT:{json.dumps({'success': True, 'actor': spawned.get_name(), 'location': [{x}, {y}, {z}]}})}")
|
|
112
|
+
else:
|
|
113
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to spawn actor'})}")
|
|
114
|
+
else:
|
|
115
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to load actor class: {class_path}'})}")
|
|
116
|
+
else:
|
|
117
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'EditorActorSubsystem not available'})}")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
63
120
|
`.trim()
|
|
64
121
|
},
|
|
65
122
|
DELETE_ACTOR: {
|
|
@@ -67,80 +124,166 @@ else:
|
|
|
67
124
|
script: `
|
|
68
125
|
import unreal
|
|
69
126
|
import json
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
130
|
+
if subsys:
|
|
131
|
+
actors = subsys.get_all_level_actors()
|
|
132
|
+
found = False
|
|
133
|
+
for actor in actors:
|
|
134
|
+
if not actor:
|
|
135
|
+
continue
|
|
136
|
+
label = actor.get_actor_label()
|
|
137
|
+
name = actor.get_name()
|
|
138
|
+
if label == "{actor_name}" or name == "{actor_name}" or label.lower().startswith("{actor_name}".lower()+"_"):
|
|
139
|
+
success = subsys.destroy_actor(actor)
|
|
140
|
+
print(f"RESULT:{json.dumps({'success': success, 'deleted': label})}")
|
|
141
|
+
found = True
|
|
142
|
+
break
|
|
143
|
+
if not found:
|
|
144
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Actor not found: {actor_name}'})}")
|
|
145
|
+
else:
|
|
146
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'EditorActorSubsystem not available'})}")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
85
149
|
`.trim()
|
|
86
150
|
},
|
|
87
151
|
CREATE_ASSET: {
|
|
88
152
|
name: 'create_asset',
|
|
89
153
|
script: `
|
|
90
154
|
import unreal
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
155
|
+
import json
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
|
159
|
+
if asset_tools:
|
|
160
|
+
# Create factory based on asset type
|
|
161
|
+
factory_class = getattr(unreal, '{factory_class}', None)
|
|
162
|
+
asset_class = getattr(unreal, '{asset_class}', None)
|
|
163
|
+
|
|
164
|
+
if factory_class and asset_class:
|
|
165
|
+
factory = factory_class()
|
|
166
|
+
# Clean up the path - remove trailing slashes and normalize
|
|
167
|
+
package_path = "{package_path}".rstrip('/').replace('//', '/')
|
|
168
|
+
|
|
169
|
+
# Ensure package path is valid (starts with /Game or /Engine)
|
|
170
|
+
if not package_path.startswith('/Game') and not package_path.startswith('/Engine'):
|
|
171
|
+
if not package_path.startswith('/'):
|
|
172
|
+
package_path = f"/Game/{package_path}"
|
|
173
|
+
else:
|
|
174
|
+
package_path = f"/Game{package_path}"
|
|
175
|
+
|
|
176
|
+
# Create full asset path for verification
|
|
177
|
+
full_asset_path = f"{package_path}/{asset_name}" if package_path != "/Game" else f"/Game/{asset_name}"
|
|
178
|
+
|
|
179
|
+
# Create the asset with cleaned path
|
|
180
|
+
asset = asset_tools.create_asset("{asset_name}", package_path, asset_class, factory)
|
|
181
|
+
if asset:
|
|
182
|
+
# Save the asset
|
|
183
|
+
saved = unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
|
184
|
+
# Enhanced verification with retry logic
|
|
185
|
+
asset_path = asset.get_path_name()
|
|
186
|
+
verification_attempts = 0
|
|
187
|
+
max_verification_attempts = 5
|
|
188
|
+
asset_verified = False
|
|
189
|
+
|
|
190
|
+
while verification_attempts < max_verification_attempts and not asset_verified:
|
|
191
|
+
verification_attempts += 1
|
|
192
|
+
# Wait a bit for the asset to be fully saved
|
|
193
|
+
import time
|
|
194
|
+
time.sleep(0.1)
|
|
195
|
+
|
|
196
|
+
# Check if asset exists
|
|
197
|
+
asset_exists = unreal.EditorAssetLibrary.does_asset_exist(asset_path)
|
|
198
|
+
|
|
199
|
+
if asset_exists:
|
|
200
|
+
asset_verified = True
|
|
201
|
+
elif verification_attempts < max_verification_attempts:
|
|
202
|
+
# Try to reload the asset registry
|
|
203
|
+
try:
|
|
204
|
+
unreal.AssetRegistryHelpers.get_asset_registry().scan_modified_asset_files([asset_path])
|
|
205
|
+
except:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
if asset_verified:
|
|
209
|
+
print(f"RESULT:{json.dumps({'success': saved, 'path': asset_path, 'verified': True})}")
|
|
210
|
+
else:
|
|
211
|
+
print(f"RESULT:{json.dumps({'success': saved, 'path': asset_path, 'warning': 'Asset created but verification pending'})}")
|
|
212
|
+
else:
|
|
213
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to create asset'})}")
|
|
214
|
+
else:
|
|
215
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Invalid factory or asset class'})}")
|
|
216
|
+
else:
|
|
217
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'AssetToolsHelpers not available'})}")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
99
220
|
`.trim()
|
|
100
221
|
},
|
|
101
222
|
SET_VIEWPORT_CAMERA: {
|
|
102
223
|
name: 'set_viewport_camera',
|
|
103
224
|
script: `
|
|
104
225
|
import unreal
|
|
226
|
+
import json
|
|
227
|
+
|
|
105
228
|
location = unreal.Vector({x}, {y}, {z})
|
|
106
229
|
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Use UnrealEditorSubsystem for viewport operations (UE5.1+)
|
|
233
|
+
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
234
|
+
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
235
|
+
|
|
236
|
+
if ues:
|
|
237
|
+
ues.set_level_viewport_camera_info(location, rotation)
|
|
238
|
+
try:
|
|
239
|
+
if les:
|
|
240
|
+
les.editor_invalidate_viewports()
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
print(f"RESULT:{json.dumps({'success': True, 'location': [{x}, {y}, {z}], 'rotation': [{pitch}, {yaw}, {roll}]}})}")
|
|
244
|
+
else:
|
|
245
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'UnrealEditorSubsystem not available'})}")
|
|
246
|
+
except Exception as e:
|
|
247
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
120
248
|
`.trim()
|
|
121
249
|
},
|
|
122
250
|
BUILD_LIGHTING: {
|
|
123
251
|
name: 'build_lighting',
|
|
124
252
|
script: `
|
|
125
253
|
import unreal
|
|
254
|
+
import json
|
|
255
|
+
|
|
126
256
|
try:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
257
|
+
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
258
|
+
if les:
|
|
259
|
+
# Use UE 5.6 enhanced lighting quality settings
|
|
260
|
+
quality_map = {
|
|
261
|
+
'Preview': unreal.LightingBuildQuality.PREVIEW,
|
|
262
|
+
'Medium': unreal.LightingBuildQuality.MEDIUM,
|
|
263
|
+
'High': unreal.LightingBuildQuality.HIGH,
|
|
264
|
+
'Production': unreal.LightingBuildQuality.PRODUCTION
|
|
265
|
+
}
|
|
266
|
+
q = quality_map.get('{quality}', unreal.LightingBuildQuality.PREVIEW)
|
|
267
|
+
les.build_light_maps(q, True)
|
|
268
|
+
print(f"RESULT:{json.dumps({'success': True, 'quality': '{quality}', 'method': 'LevelEditorSubsystem'})}")
|
|
269
|
+
else:
|
|
270
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'LevelEditorSubsystem not available'})}")
|
|
134
271
|
except Exception as e:
|
|
135
|
-
|
|
272
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
136
273
|
`.trim()
|
|
137
274
|
},
|
|
138
275
|
SAVE_ALL_DIRTY_PACKAGES: {
|
|
139
276
|
name: 'save_dirty_packages',
|
|
140
277
|
script: `
|
|
141
278
|
import unreal
|
|
142
|
-
|
|
143
|
-
|
|
279
|
+
import json
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
# Use UE 5.6 enhanced saving with better error handling
|
|
283
|
+
saved = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
|
284
|
+
print(f"RESULT:{json.dumps({'success': bool(saved), 'saved_count': saved if isinstance(saved, int) else 0, 'message': 'All dirty packages saved'})}")
|
|
285
|
+
except Exception as e:
|
|
286
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e), 'message': 'Failed to save dirty packages'})}")
|
|
144
287
|
`.trim()
|
|
145
288
|
}
|
|
146
289
|
};
|
|
@@ -338,14 +481,64 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
338
481
|
const url = path.startsWith('/') ? path : `/${path}`;
|
|
339
482
|
const started = Date.now();
|
|
340
483
|
// Fix Content-Length header issue - ensure body is properly handled
|
|
341
|
-
|
|
342
|
-
|
|
484
|
+
let payload = body;
|
|
485
|
+
if ((payload === undefined || payload === null) && method !== 'GET') {
|
|
486
|
+
payload = {};
|
|
487
|
+
}
|
|
488
|
+
// Add timeout wrapper to prevent hanging - adjust based on operation type
|
|
489
|
+
let CALL_TIMEOUT = 10000; // Default 10 seconds timeout
|
|
490
|
+
const longRunningTimeout = 10 * 60 * 1000; // 10 minutes for heavy editor jobs
|
|
491
|
+
// Use payload contents to detect long-running editor operations
|
|
492
|
+
let payloadSignature = '';
|
|
493
|
+
if (typeof payload === 'string') {
|
|
494
|
+
payloadSignature = payload;
|
|
495
|
+
}
|
|
496
|
+
else if (payload && typeof payload === 'object') {
|
|
497
|
+
try {
|
|
498
|
+
payloadSignature = JSON.stringify(payload);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
payloadSignature = '';
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Allow explicit override via meta property when provided
|
|
505
|
+
let sanitizedPayload = payload;
|
|
506
|
+
if (payload && typeof payload === 'object' && '__callTimeoutMs' in payload) {
|
|
507
|
+
const overrideRaw = payload.__callTimeoutMs;
|
|
508
|
+
const overrideMs = typeof overrideRaw === 'number'
|
|
509
|
+
? overrideRaw
|
|
510
|
+
: Number.parseInt(String(overrideRaw), 10);
|
|
511
|
+
if (Number.isFinite(overrideMs) && overrideMs > 0) {
|
|
512
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, overrideMs);
|
|
513
|
+
}
|
|
514
|
+
sanitizedPayload = { ...payload };
|
|
515
|
+
delete sanitizedPayload.__callTimeoutMs;
|
|
516
|
+
}
|
|
517
|
+
// For heavy operations, use longer timeout based on URL or payload signature
|
|
518
|
+
if (url.includes('build') || url.includes('create') || url.includes('asset')) {
|
|
519
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, 30000); // 30 seconds for heavy operations
|
|
520
|
+
}
|
|
521
|
+
if (url.includes('light') || url.includes('BuildLighting')) {
|
|
522
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, 60000); // Base 60 seconds for lighting builds
|
|
523
|
+
}
|
|
524
|
+
if (payloadSignature) {
|
|
525
|
+
const longRunningPatterns = [
|
|
526
|
+
/build_light_maps/i,
|
|
527
|
+
/lightingbuildquality/i,
|
|
528
|
+
/editorbuildlibrary/i,
|
|
529
|
+
/buildlighting/i,
|
|
530
|
+
/"command"\s*:\s*"buildlighting/i
|
|
531
|
+
];
|
|
532
|
+
if (longRunningPatterns.some(pattern => pattern.test(payloadSignature))) {
|
|
533
|
+
if (CALL_TIMEOUT < longRunningTimeout) {
|
|
534
|
+
this.log.debug(`Detected long-running lighting operation, extending HTTP timeout to ${longRunningTimeout}ms`);
|
|
535
|
+
}
|
|
536
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, longRunningTimeout);
|
|
537
|
+
}
|
|
343
538
|
}
|
|
344
|
-
// Add timeout wrapper to prevent hanging
|
|
345
|
-
const CALL_TIMEOUT = 10000; // 10 seconds timeout
|
|
346
539
|
// CRITICAL: Intercept and block dangerous console commands at HTTP level
|
|
347
|
-
if (url === '/remote/object/call' &&
|
|
348
|
-
const command =
|
|
540
|
+
if (url === '/remote/object/call' && payload?.functionName === 'ExecuteConsoleCommand') {
|
|
541
|
+
const command = payload?.parameters?.Command;
|
|
349
542
|
if (command && typeof command === 'string') {
|
|
350
543
|
const cmdLower = command.trim().toLowerCase();
|
|
351
544
|
// List of commands that cause crashes
|
|
@@ -385,22 +578,36 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
385
578
|
try {
|
|
386
579
|
// For GET requests, send payload as query parameters (not in body)
|
|
387
580
|
const config = { url, method, timeout: CALL_TIMEOUT };
|
|
388
|
-
if (method === 'GET' &&
|
|
389
|
-
config.params =
|
|
581
|
+
if (method === 'GET' && sanitizedPayload && typeof sanitizedPayload === 'object') {
|
|
582
|
+
config.params = sanitizedPayload;
|
|
390
583
|
}
|
|
391
|
-
else if (
|
|
392
|
-
config.data =
|
|
584
|
+
else if (sanitizedPayload !== undefined) {
|
|
585
|
+
config.data = sanitizedPayload;
|
|
393
586
|
}
|
|
394
587
|
// Wrap with timeout promise to ensure we don't hang
|
|
395
588
|
const requestPromise = this.http.request(config);
|
|
396
|
-
const
|
|
397
|
-
setTimeout(() => {
|
|
398
|
-
|
|
589
|
+
const resp = await new Promise((resolve, reject) => {
|
|
590
|
+
const timer = setTimeout(() => {
|
|
591
|
+
const err = new Error(`Request timeout after ${CALL_TIMEOUT}ms`);
|
|
592
|
+
err.code = 'UE_HTTP_TIMEOUT';
|
|
593
|
+
reject(err);
|
|
399
594
|
}, CALL_TIMEOUT);
|
|
595
|
+
requestPromise.then(result => {
|
|
596
|
+
clearTimeout(timer);
|
|
597
|
+
resolve(result);
|
|
598
|
+
}).catch(err => {
|
|
599
|
+
clearTimeout(timer);
|
|
600
|
+
reject(err);
|
|
601
|
+
});
|
|
400
602
|
});
|
|
401
|
-
const resp = await Promise.race([requestPromise, timeoutPromise]);
|
|
402
603
|
const ms = Date.now() - started;
|
|
403
|
-
|
|
604
|
+
// Add connection health check for long-running requests
|
|
605
|
+
if (ms > 5000) {
|
|
606
|
+
this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms (long request)`);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms`);
|
|
610
|
+
}
|
|
404
611
|
return resp.data;
|
|
405
612
|
}
|
|
406
613
|
catch (error) {
|
|
@@ -424,6 +631,151 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
424
631
|
}
|
|
425
632
|
throw lastError;
|
|
426
633
|
}
|
|
634
|
+
parsePythonJsonResult(raw) {
|
|
635
|
+
if (!raw) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
const fragments = [];
|
|
639
|
+
if (typeof raw === 'string') {
|
|
640
|
+
fragments.push(raw);
|
|
641
|
+
}
|
|
642
|
+
if (typeof raw?.Output === 'string') {
|
|
643
|
+
fragments.push(raw.Output);
|
|
644
|
+
}
|
|
645
|
+
if (typeof raw?.ReturnValue === 'string') {
|
|
646
|
+
fragments.push(raw.ReturnValue);
|
|
647
|
+
}
|
|
648
|
+
if (Array.isArray(raw?.LogOutput)) {
|
|
649
|
+
for (const entry of raw.LogOutput) {
|
|
650
|
+
if (!entry)
|
|
651
|
+
continue;
|
|
652
|
+
if (typeof entry === 'string') {
|
|
653
|
+
fragments.push(entry);
|
|
654
|
+
}
|
|
655
|
+
else if (typeof entry?.Output === 'string') {
|
|
656
|
+
fragments.push(entry.Output);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const combined = fragments.join('\n');
|
|
661
|
+
const match = combined.match(/RESULT:(\{.*\}|\[.*\])/s);
|
|
662
|
+
if (!match) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
return JSON.parse(match[1]);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async ensurePluginsEnabled(pluginNames, context) {
|
|
673
|
+
if (!pluginNames || pluginNames.length === 0) {
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
const now = Date.now();
|
|
677
|
+
const pluginsToCheck = pluginNames.filter((name) => {
|
|
678
|
+
const cached = this.pluginStatusCache.get(name);
|
|
679
|
+
if (!cached)
|
|
680
|
+
return true;
|
|
681
|
+
if (now - cached.timestamp > this.PLUGIN_CACHE_TTL) {
|
|
682
|
+
this.pluginStatusCache.delete(name);
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
return false;
|
|
686
|
+
});
|
|
687
|
+
if (pluginsToCheck.length > 0) {
|
|
688
|
+
const python = `
|
|
689
|
+
import unreal
|
|
690
|
+
import json
|
|
691
|
+
|
|
692
|
+
plugins = ${JSON.stringify(pluginsToCheck)}
|
|
693
|
+
status = {}
|
|
694
|
+
|
|
695
|
+
def get_plugin_manager():
|
|
696
|
+
try:
|
|
697
|
+
return unreal.PluginManager.get()
|
|
698
|
+
except AttributeError:
|
|
699
|
+
return None
|
|
700
|
+
except Exception:
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
def get_plugins_subsystem():
|
|
704
|
+
try:
|
|
705
|
+
return unreal.get_editor_subsystem(unreal.PluginsEditorSubsystem)
|
|
706
|
+
except AttributeError:
|
|
707
|
+
pass
|
|
708
|
+
except Exception:
|
|
709
|
+
pass
|
|
710
|
+
try:
|
|
711
|
+
return unreal.PluginsSubsystem()
|
|
712
|
+
except Exception:
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
pm = get_plugin_manager()
|
|
716
|
+
ps = get_plugins_subsystem()
|
|
717
|
+
|
|
718
|
+
def is_enabled(plugin_name):
|
|
719
|
+
if pm:
|
|
720
|
+
try:
|
|
721
|
+
if pm.is_plugin_enabled(plugin_name):
|
|
722
|
+
return True
|
|
723
|
+
except Exception:
|
|
724
|
+
try:
|
|
725
|
+
plugin = pm.find_plugin(plugin_name)
|
|
726
|
+
if plugin and plugin.is_enabled():
|
|
727
|
+
return True
|
|
728
|
+
except Exception:
|
|
729
|
+
pass
|
|
730
|
+
if ps:
|
|
731
|
+
try:
|
|
732
|
+
return bool(ps.is_plugin_enabled(plugin_name))
|
|
733
|
+
except Exception:
|
|
734
|
+
try:
|
|
735
|
+
plugin = ps.find_plugin(plugin_name)
|
|
736
|
+
if plugin and plugin.is_enabled():
|
|
737
|
+
return True
|
|
738
|
+
except Exception:
|
|
739
|
+
pass
|
|
740
|
+
return False
|
|
741
|
+
|
|
742
|
+
for plugin_name in plugins:
|
|
743
|
+
enabled = False
|
|
744
|
+
try:
|
|
745
|
+
enabled = is_enabled(plugin_name)
|
|
746
|
+
except Exception:
|
|
747
|
+
enabled = False
|
|
748
|
+
status[plugin_name] = bool(enabled)
|
|
749
|
+
|
|
750
|
+
print('RESULT:' + json.dumps(status))
|
|
751
|
+
`.trim();
|
|
752
|
+
try {
|
|
753
|
+
const response = await this.executePython(python);
|
|
754
|
+
const parsed = this.parsePythonJsonResult(response);
|
|
755
|
+
if (parsed) {
|
|
756
|
+
for (const [name, enabled] of Object.entries(parsed)) {
|
|
757
|
+
this.pluginStatusCache.set(name, { enabled: Boolean(enabled), timestamp: now });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
this.log.warn('Failed to parse plugin status response', { context, pluginsToCheck });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
this.log.warn('Plugin status check failed', { context, pluginsToCheck, error: error?.message ?? error });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
for (const name of pluginNames) {
|
|
769
|
+
if (!this.pluginStatusCache.has(name)) {
|
|
770
|
+
this.pluginStatusCache.set(name, { enabled: false, timestamp: now });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const missing = pluginNames.filter((name) => !this.pluginStatusCache.get(name)?.enabled);
|
|
774
|
+
if (missing.length && context) {
|
|
775
|
+
this.log.warn(`Missing required Unreal plugins for ${context}: ${missing.join(', ')}`);
|
|
776
|
+
}
|
|
777
|
+
return missing;
|
|
778
|
+
}
|
|
427
779
|
// Generic function call via Remote Control HTTP API
|
|
428
780
|
async call(body) {
|
|
429
781
|
if (!this.connected)
|
|
@@ -441,10 +793,11 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
441
793
|
return this.httpCall('/remote/preset', 'GET');
|
|
442
794
|
}
|
|
443
795
|
// Execute a console command safely with validation and throttling
|
|
444
|
-
async executeConsoleCommand(command) {
|
|
796
|
+
async executeConsoleCommand(command, options = {}) {
|
|
445
797
|
if (!this.connected) {
|
|
446
798
|
throw new Error('Not connected to Unreal Engine');
|
|
447
799
|
}
|
|
800
|
+
const { allowPython = false } = options;
|
|
448
801
|
// Validate command is not empty
|
|
449
802
|
if (!command || typeof command !== 'string') {
|
|
450
803
|
throw new Error('Invalid command: must be a non-empty string');
|
|
@@ -454,6 +807,13 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
454
807
|
// Return success for empty commands to match UE behavior
|
|
455
808
|
return { success: true, message: 'Empty command ignored' };
|
|
456
809
|
}
|
|
810
|
+
if (cmdTrimmed.includes('\n') || cmdTrimmed.includes('\r')) {
|
|
811
|
+
throw new Error('Multi-line console commands are not allowed. Send one command per call.');
|
|
812
|
+
}
|
|
813
|
+
const cmdLower = cmdTrimmed.toLowerCase();
|
|
814
|
+
if (!allowPython && (cmdLower === 'py' || cmdLower.startsWith('py '))) {
|
|
815
|
+
throw new Error('Python console commands are blocked from external calls for safety.');
|
|
816
|
+
}
|
|
457
817
|
// Check for dangerous commands
|
|
458
818
|
const dangerousCommands = [
|
|
459
819
|
'quit', 'exit', 'delete', 'destroy', 'kill', 'crash',
|
|
@@ -463,10 +823,22 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
463
823
|
'buildpaths', // Can cause access violation if nav system not initialized
|
|
464
824
|
'rebuildnavigation' // Can also crash without proper nav setup
|
|
465
825
|
];
|
|
466
|
-
const cmdLower = cmdTrimmed.toLowerCase();
|
|
467
826
|
if (dangerousCommands.some(dangerous => cmdLower.includes(dangerous))) {
|
|
468
827
|
throw new Error(`Dangerous command blocked: ${command}`);
|
|
469
828
|
}
|
|
829
|
+
const forbiddenTokens = [
|
|
830
|
+
'rm ', 'rm-', 'del ', 'format ', 'shutdown', 'reboot',
|
|
831
|
+
'rmdir', 'mklink', 'copy ', 'move ', 'start "', 'system(',
|
|
832
|
+
'import os', 'import subprocess', 'subprocess.', 'os.system',
|
|
833
|
+
'exec(', 'eval(', '__import__', 'import sys', 'import importlib',
|
|
834
|
+
'with open', 'open('
|
|
835
|
+
];
|
|
836
|
+
if (cmdLower.includes('&&') || cmdLower.includes('||')) {
|
|
837
|
+
throw new Error('Command chaining with && or || is blocked for safety.');
|
|
838
|
+
}
|
|
839
|
+
if (forbiddenTokens.some(token => cmdLower.includes(token))) {
|
|
840
|
+
throw new Error(`Command contains unsafe token and was blocked: ${command}`);
|
|
841
|
+
}
|
|
470
842
|
// Determine priority based on command type
|
|
471
843
|
let priority = 7; // Default priority
|
|
472
844
|
if (command.includes('BuildLighting') || command.includes('BuildPaths')) {
|
|
@@ -506,6 +878,75 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
506
878
|
throw error;
|
|
507
879
|
}
|
|
508
880
|
}
|
|
881
|
+
summarizeConsoleCommand(command, response) {
|
|
882
|
+
const trimmedCommand = command.trim();
|
|
883
|
+
const logLines = Array.isArray(response?.LogOutput)
|
|
884
|
+
? response.LogOutput.map(entry => {
|
|
885
|
+
if (entry === null || entry === undefined) {
|
|
886
|
+
return '';
|
|
887
|
+
}
|
|
888
|
+
if (typeof entry === 'string') {
|
|
889
|
+
return entry;
|
|
890
|
+
}
|
|
891
|
+
return typeof entry.Output === 'string' ? entry.Output : '';
|
|
892
|
+
}).filter(Boolean)
|
|
893
|
+
: [];
|
|
894
|
+
let output = logLines.join('\n').trim();
|
|
895
|
+
if (!output) {
|
|
896
|
+
if (typeof response === 'string') {
|
|
897
|
+
output = response.trim();
|
|
898
|
+
}
|
|
899
|
+
else if (response && typeof response === 'object') {
|
|
900
|
+
if (typeof response.Output === 'string') {
|
|
901
|
+
output = response.Output.trim();
|
|
902
|
+
}
|
|
903
|
+
else if ('result' in response && response.result !== undefined) {
|
|
904
|
+
output = String(response.result).trim();
|
|
905
|
+
}
|
|
906
|
+
else if ('ReturnValue' in response && typeof response.ReturnValue === 'string') {
|
|
907
|
+
output = response.ReturnValue.trim();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const returnValue = response && typeof response === 'object' && 'ReturnValue' in response
|
|
912
|
+
? response.ReturnValue
|
|
913
|
+
: undefined;
|
|
914
|
+
return {
|
|
915
|
+
command: trimmedCommand,
|
|
916
|
+
output,
|
|
917
|
+
logLines,
|
|
918
|
+
returnValue,
|
|
919
|
+
raw: response
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
async executeConsoleCommands(commands, options = {}) {
|
|
923
|
+
const { continueOnError = false, delayMs = 0 } = options;
|
|
924
|
+
const results = [];
|
|
925
|
+
for (const rawCommand of commands) {
|
|
926
|
+
const descriptor = typeof rawCommand === 'string' ? { command: rawCommand } : rawCommand;
|
|
927
|
+
const command = descriptor.command?.trim();
|
|
928
|
+
if (!command) {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
try {
|
|
932
|
+
const result = await this.executeConsoleCommand(command, {
|
|
933
|
+
allowPython: Boolean(descriptor.allowPython)
|
|
934
|
+
});
|
|
935
|
+
results.push(result);
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
if (!continueOnError) {
|
|
939
|
+
throw error;
|
|
940
|
+
}
|
|
941
|
+
this.log.warn(`Console batch command failed: ${command}`, error);
|
|
942
|
+
results.push(error);
|
|
943
|
+
}
|
|
944
|
+
if (delayMs > 0) {
|
|
945
|
+
await this.delay(delayMs);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return results;
|
|
949
|
+
}
|
|
509
950
|
// Try to execute a Python command via the PythonScriptPlugin, fallback to `py` console command.
|
|
510
951
|
async executePython(command) {
|
|
511
952
|
if (!this.connected) {
|
|
@@ -542,7 +983,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
542
983
|
this.log.warn('PythonScriptLibrary not available or failed, falling back to console `py` command');
|
|
543
984
|
// For simple single-line commands
|
|
544
985
|
if (!isMultiLine) {
|
|
545
|
-
return await this.executeConsoleCommand(`py ${command}
|
|
986
|
+
return await this.executeConsoleCommand(`py ${command}`, { allowPython: true });
|
|
546
987
|
}
|
|
547
988
|
// For multi-line scripts, try to execute as a block
|
|
548
989
|
try {
|
|
@@ -553,7 +994,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
553
994
|
.replace(/"/g, '\\"')
|
|
554
995
|
.replace(/\n/g, '\\n')
|
|
555
996
|
.replace(/\r/g, '');
|
|
556
|
-
return await this.executeConsoleCommand(`py exec("${escapedScript}")
|
|
997
|
+
return await this.executeConsoleCommand(`py exec("${escapedScript}")`, { allowPython: true });
|
|
557
998
|
}
|
|
558
999
|
catch {
|
|
559
1000
|
// If that fails, break into smaller chunks
|
|
@@ -573,10 +1014,10 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
573
1014
|
if (stmt.length > 200) {
|
|
574
1015
|
// Try to execute as a single exec block
|
|
575
1016
|
const miniScript = `exec("""${stmt.replace(/"/g, '\\"')}""")`;
|
|
576
|
-
result = await this.executeConsoleCommand(`py ${miniScript}
|
|
1017
|
+
result = await this.executeConsoleCommand(`py ${miniScript}`, { allowPython: true });
|
|
577
1018
|
}
|
|
578
1019
|
else {
|
|
579
|
-
result = await this.executeConsoleCommand(`py ${stmt}
|
|
1020
|
+
result = await this.executeConsoleCommand(`py ${stmt}`, { allowPython: true });
|
|
580
1021
|
}
|
|
581
1022
|
// Small delay between commands
|
|
582
1023
|
await new Promise(resolve => setTimeout(resolve, 30));
|
|
@@ -592,7 +1033,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
592
1033
|
if (line.trim().startsWith('#')) {
|
|
593
1034
|
continue;
|
|
594
1035
|
}
|
|
595
|
-
result = await this.executeConsoleCommand(`py ${line.trim()}
|
|
1036
|
+
result = await this.executeConsoleCommand(`py ${line.trim()}`, { allowPython: true });
|
|
596
1037
|
// Small delay between commands to ensure execution order
|
|
597
1038
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
598
1039
|
}
|
|
@@ -809,6 +1250,10 @@ finally:
|
|
|
809
1250
|
* Get the Unreal Engine version via Python and parse major/minor/patch.
|
|
810
1251
|
*/
|
|
811
1252
|
async getEngineVersion() {
|
|
1253
|
+
const now = Date.now();
|
|
1254
|
+
if (this.engineVersionCache && now - this.engineVersionCache.timestamp < this.ENGINE_VERSION_TTL_MS) {
|
|
1255
|
+
return this.engineVersionCache.value;
|
|
1256
|
+
}
|
|
812
1257
|
try {
|
|
813
1258
|
const script = `
|
|
814
1259
|
import unreal, json, re
|
|
@@ -825,11 +1270,15 @@ print('RESULT:' + json.dumps({'version': ver, 'major': major, 'minor': minor, 'p
|
|
|
825
1270
|
const minor = Number(result?.minor ?? 0) || 0;
|
|
826
1271
|
const patch = Number(result?.patch ?? 0) || 0;
|
|
827
1272
|
const isUE56OrAbove = major > 5 || (major === 5 && minor >= 6);
|
|
828
|
-
|
|
1273
|
+
const value = { version, major, minor, patch, isUE56OrAbove };
|
|
1274
|
+
this.engineVersionCache = { value, timestamp: now };
|
|
1275
|
+
return value;
|
|
829
1276
|
}
|
|
830
1277
|
catch (error) {
|
|
831
1278
|
this.log.warn('Failed to get engine version via Python', error);
|
|
832
|
-
|
|
1279
|
+
const fallback = { version: 'unknown', major: 0, minor: 0, patch: 0, isUE56OrAbove: false };
|
|
1280
|
+
this.engineVersionCache = { value: fallback, timestamp: now };
|
|
1281
|
+
return fallback;
|
|
833
1282
|
}
|
|
834
1283
|
}
|
|
835
1284
|
/**
|
|
@@ -897,38 +1346,63 @@ print('RESULT:' + json.dumps(flags))
|
|
|
897
1346
|
* Prevent crashes by validating and safely switching viewmodes
|
|
898
1347
|
*/
|
|
899
1348
|
async setSafeViewMode(mode) {
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1349
|
+
const acceptedModes = Array.from(new Set(this.VIEWMODE_ALIASES.values())).sort();
|
|
1350
|
+
if (typeof mode !== 'string') {
|
|
1351
|
+
return {
|
|
1352
|
+
success: false,
|
|
1353
|
+
error: 'View mode must be provided as a string',
|
|
1354
|
+
acceptedModes
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
const key = mode.trim().toLowerCase().replace(/[\s_-]+/g, '');
|
|
1358
|
+
if (!key) {
|
|
1359
|
+
return {
|
|
1360
|
+
success: false,
|
|
1361
|
+
error: 'View mode cannot be empty',
|
|
1362
|
+
acceptedModes
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
const targetMode = this.VIEWMODE_ALIASES.get(key);
|
|
1366
|
+
if (!targetMode) {
|
|
1367
|
+
return {
|
|
1368
|
+
success: false,
|
|
1369
|
+
error: `Unknown view mode '${mode}'`,
|
|
1370
|
+
acceptedModes
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
if (this.HARD_BLOCKED_VIEWMODES.has(targetMode)) {
|
|
1374
|
+
this.log.warn(`Viewmode '${targetMode}' is blocked for safety. Using alternative.`);
|
|
1375
|
+
const alternative = this.getSafeAlternative(targetMode);
|
|
1376
|
+
const altCommand = `viewmode ${alternative}`;
|
|
1377
|
+
const altResult = await this.executeConsoleCommand(altCommand);
|
|
1378
|
+
const altSummary = this.summarizeConsoleCommand(altCommand, altResult);
|
|
1379
|
+
return {
|
|
1380
|
+
...altSummary,
|
|
1381
|
+
success: false,
|
|
1382
|
+
requestedMode: targetMode,
|
|
1383
|
+
viewMode: alternative,
|
|
1384
|
+
message: `View mode '${targetMode}' is unsafe in remote sessions. Switched to '${alternative}'.`,
|
|
1385
|
+
alternative
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
const command = `viewmode ${targetMode}`;
|
|
1389
|
+
const rawResult = await this.executeConsoleCommand(command);
|
|
1390
|
+
const summary = this.summarizeConsoleCommand(command, rawResult);
|
|
1391
|
+
const response = {
|
|
1392
|
+
...summary,
|
|
1393
|
+
success: summary.returnValue !== false,
|
|
1394
|
+
requestedMode: targetMode,
|
|
1395
|
+
viewMode: targetMode,
|
|
1396
|
+
message: `View mode set to ${targetMode}`
|
|
1397
|
+
};
|
|
1398
|
+
if (this.UNSAFE_VIEWMODES.includes(targetMode)) {
|
|
1399
|
+
response.warning = `View mode '${targetMode}' may be unstable on some engine versions.`;
|
|
1400
|
+
}
|
|
1401
|
+
if (summary.output && /unknown|invalid/i.test(summary.output)) {
|
|
1402
|
+
response.success = false;
|
|
1403
|
+
response.error = summary.output;
|
|
1404
|
+
}
|
|
1405
|
+
return response;
|
|
932
1406
|
}
|
|
933
1407
|
/**
|
|
934
1408
|
* Get safe alternative for unsafe viewmodes
|
|
@@ -940,6 +1414,8 @@ print('RESULT:' + json.dumps(flags))
|
|
|
940
1414
|
'Metallic': 'Lit',
|
|
941
1415
|
'Specular': 'Lit',
|
|
942
1416
|
'Roughness': 'Lit',
|
|
1417
|
+
'SubsurfaceColor': 'Lit',
|
|
1418
|
+
'Opacity': 'Lit',
|
|
943
1419
|
'LightComplexity': 'LightingOnly',
|
|
944
1420
|
'ShaderComplexity': 'Wireframe',
|
|
945
1421
|
'CollisionPawn': 'Wireframe',
|
|
@@ -1042,7 +1518,10 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1042
1518
|
}
|
|
1043
1519
|
// Priority 7,9-10: Light operations (console commands, queries)
|
|
1044
1520
|
else {
|
|
1045
|
-
|
|
1521
|
+
// For light operations, add some jitter to prevent thundering herd
|
|
1522
|
+
const baseDelay = this.MIN_COMMAND_DELAY;
|
|
1523
|
+
const jitter = Math.random() * 50; // Add up to 50ms random jitter
|
|
1524
|
+
return baseDelay + jitter;
|
|
1046
1525
|
}
|
|
1047
1526
|
}
|
|
1048
1527
|
/**
|
|
@@ -1084,6 +1563,26 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1084
1563
|
this.processCommandQueue();
|
|
1085
1564
|
}
|
|
1086
1565
|
}, 1000);
|
|
1566
|
+
// Clean console cache every 5 minutes
|
|
1567
|
+
setInterval(() => {
|
|
1568
|
+
this.cleanConsoleCache();
|
|
1569
|
+
}, this.CONSOLE_CACHE_TTL);
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Clean expired entries from console object cache
|
|
1573
|
+
*/
|
|
1574
|
+
cleanConsoleCache() {
|
|
1575
|
+
const now = Date.now();
|
|
1576
|
+
let cleaned = 0;
|
|
1577
|
+
for (const [key, value] of this.consoleObjectCache.entries()) {
|
|
1578
|
+
if (now - (value.timestamp || 0) > this.CONSOLE_CACHE_TTL) {
|
|
1579
|
+
this.consoleObjectCache.delete(key);
|
|
1580
|
+
cleaned++;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (cleaned > 0) {
|
|
1584
|
+
this.log.debug(`Cleaned ${cleaned} expired console cache entries`);
|
|
1585
|
+
}
|
|
1087
1586
|
}
|
|
1088
1587
|
/**
|
|
1089
1588
|
* Helper delay function
|
|
@@ -1095,12 +1594,7 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1095
1594
|
* Batch command execution with proper delays
|
|
1096
1595
|
*/
|
|
1097
1596
|
async executeBatch(commands) {
|
|
1098
|
-
|
|
1099
|
-
for (const cmd of commands) {
|
|
1100
|
-
const result = await this.executeConsoleCommand(cmd.command);
|
|
1101
|
-
results.push(result);
|
|
1102
|
-
}
|
|
1103
|
-
return results;
|
|
1597
|
+
return this.executeConsoleCommands(commands.map(cmd => cmd.command));
|
|
1104
1598
|
}
|
|
1105
1599
|
/**
|
|
1106
1600
|
* Get safe console commands for common operations
|