unreal-engine-mcp-server 0.4.0 → 0.4.3
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 +1 -1
- 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/src/unreal-bridge.ts
CHANGED
|
@@ -41,6 +41,8 @@ export class UnrealBridge {
|
|
|
41
41
|
private readonly MAX_RECONNECT_ATTEMPTS = 5;
|
|
42
42
|
private readonly BASE_RECONNECT_DELAY = 1000;
|
|
43
43
|
private autoReconnectEnabled = false; // disabled by default to prevent looping retries
|
|
44
|
+
private engineVersionCache?: { value: { version: string; major: number; minor: number; patch: number; isUE56OrAbove: boolean }; timestamp: number };
|
|
45
|
+
private readonly ENGINE_VERSION_TTL_MS = 5 * 60 * 1000;
|
|
44
46
|
|
|
45
47
|
// Command queue for throttling
|
|
46
48
|
private commandQueue: CommandQueueItem[] = [];
|
|
@@ -50,20 +52,54 @@ export class UnrealBridge {
|
|
|
50
52
|
private readonly STAT_COMMAND_DELAY = 300; // Special delay for stat commands to avoid warnings
|
|
51
53
|
private lastCommandTime = 0;
|
|
52
54
|
private lastStatCommandTime = 0; // Track stat commands separately
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
|
|
56
|
+
// Console object cache to reduce FindConsoleObject warnings
|
|
57
|
+
private consoleObjectCache = new Map<string, any>();
|
|
58
|
+
private readonly CONSOLE_CACHE_TTL = 300000; // 5 minutes TTL for cached objects
|
|
59
|
+
private pluginStatusCache = new Map<string, { enabled: boolean; timestamp: number }>();
|
|
60
|
+
private readonly PLUGIN_CACHE_TTL = 5 * 60 * 1000;
|
|
59
61
|
|
|
60
62
|
// Unsafe viewmodes that can cause crashes or instability via visualizeBuffer
|
|
61
63
|
private readonly UNSAFE_VIEWMODES = [
|
|
62
64
|
'BaseColor', 'WorldNormal', 'Metallic', 'Specular',
|
|
63
|
-
|
|
65
|
+
'Roughness',
|
|
66
|
+
'SubsurfaceColor',
|
|
67
|
+
'Opacity',
|
|
64
68
|
'LightComplexity', 'LightmapDensity',
|
|
65
69
|
'StationaryLightOverlap', 'CollisionPawn', 'CollisionVisibility'
|
|
66
70
|
];
|
|
71
|
+
private readonly HARD_BLOCKED_VIEWMODES = new Set([
|
|
72
|
+
'BaseColor', 'WorldNormal', 'Metallic', 'Specular', 'Roughness', 'SubsurfaceColor', 'Opacity'
|
|
73
|
+
]);
|
|
74
|
+
private readonly VIEWMODE_ALIASES = new Map<string, string>([
|
|
75
|
+
['lit', 'Lit'],
|
|
76
|
+
['unlit', 'Unlit'],
|
|
77
|
+
['wireframe', 'Wireframe'],
|
|
78
|
+
['brushwireframe', 'BrushWireframe'],
|
|
79
|
+
['brush_wireframe', 'BrushWireframe'],
|
|
80
|
+
['detaillighting', 'DetailLighting'],
|
|
81
|
+
['detail_lighting', 'DetailLighting'],
|
|
82
|
+
['lightingonly', 'LightingOnly'],
|
|
83
|
+
['lighting_only', 'LightingOnly'],
|
|
84
|
+
['lightonly', 'LightingOnly'],
|
|
85
|
+
['light_only', 'LightingOnly'],
|
|
86
|
+
['lightcomplexity', 'LightComplexity'],
|
|
87
|
+
['light_complexity', 'LightComplexity'],
|
|
88
|
+
['shadercomplexity', 'ShaderComplexity'],
|
|
89
|
+
['shader_complexity', 'ShaderComplexity'],
|
|
90
|
+
['lightmapdensity', 'LightmapDensity'],
|
|
91
|
+
['lightmap_density', 'LightmapDensity'],
|
|
92
|
+
['stationarylightoverlap', 'StationaryLightOverlap'],
|
|
93
|
+
['stationary_light_overlap', 'StationaryLightOverlap'],
|
|
94
|
+
['reflectionoverride', 'ReflectionOverride'],
|
|
95
|
+
['reflection_override', 'ReflectionOverride'],
|
|
96
|
+
['texeldensity', 'TexelDensity'],
|
|
97
|
+
['texel_density', 'TexelDensity'],
|
|
98
|
+
['vertexcolor', 'VertexColor'],
|
|
99
|
+
['vertex_color', 'VertexColor'],
|
|
100
|
+
['litdetail', 'DetailLighting'],
|
|
101
|
+
['lit_only', 'LightingOnly']
|
|
102
|
+
]);
|
|
67
103
|
|
|
68
104
|
// Python script templates for EditorLevelLibrary access
|
|
69
105
|
private readonly PYTHON_TEMPLATES: Record<string, PythonScriptTemplate> = {
|
|
@@ -71,27 +107,48 @@ export class UnrealBridge {
|
|
|
71
107
|
name: 'get_all_actors',
|
|
72
108
|
script: `
|
|
73
109
|
import unreal
|
|
110
|
+
import json
|
|
111
|
+
|
|
74
112
|
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
113
|
+
try:
|
|
114
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
115
|
+
if subsys:
|
|
116
|
+
actors = subsys.get_all_level_actors()
|
|
117
|
+
result = [{'name': a.get_name(), 'class': a.get_class().get_name(), 'path': a.get_path_name()} for a in actors]
|
|
118
|
+
print(f"RESULT:{json.dumps(result)}")
|
|
119
|
+
else:
|
|
120
|
+
print("RESULT:[]")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"RESULT:{json.dumps({'error': str(e)})}")
|
|
79
123
|
`.trim()
|
|
80
124
|
},
|
|
81
125
|
SPAWN_ACTOR_AT_LOCATION: {
|
|
82
126
|
name: 'spawn_actor',
|
|
83
127
|
script: `
|
|
84
128
|
import unreal
|
|
129
|
+
import json
|
|
130
|
+
|
|
85
131
|
location = unreal.Vector({x}, {y}, {z})
|
|
86
132
|
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
136
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
137
|
+
if subsys:
|
|
138
|
+
# Try to load asset class
|
|
139
|
+
actor_class = unreal.EditorAssetLibrary.load_asset("{class_path}")
|
|
140
|
+
if actor_class:
|
|
141
|
+
spawned = subsys.spawn_actor_from_object(actor_class, location, rotation)
|
|
142
|
+
if spawned:
|
|
143
|
+
print(f"RESULT:{json.dumps({'success': True, 'actor': spawned.get_name(), 'location': [{x}, {y}, {z}]}})}")
|
|
144
|
+
else:
|
|
145
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to spawn actor'})}")
|
|
146
|
+
else:
|
|
147
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to load actor class: {class_path}'})}")
|
|
148
|
+
else:
|
|
149
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'EditorActorSubsystem not available'})}")
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
95
152
|
`.trim()
|
|
96
153
|
},
|
|
97
154
|
DELETE_ACTOR: {
|
|
@@ -99,80 +156,166 @@ else:
|
|
|
99
156
|
script: `
|
|
100
157
|
import unreal
|
|
101
158
|
import json
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
162
|
+
if subsys:
|
|
163
|
+
actors = subsys.get_all_level_actors()
|
|
164
|
+
found = False
|
|
165
|
+
for actor in actors:
|
|
166
|
+
if not actor:
|
|
167
|
+
continue
|
|
168
|
+
label = actor.get_actor_label()
|
|
169
|
+
name = actor.get_name()
|
|
170
|
+
if label == "{actor_name}" or name == "{actor_name}" or label.lower().startswith("{actor_name}".lower()+"_"):
|
|
171
|
+
success = subsys.destroy_actor(actor)
|
|
172
|
+
print(f"RESULT:{json.dumps({'success': success, 'deleted': label})}")
|
|
173
|
+
found = True
|
|
174
|
+
break
|
|
175
|
+
if not found:
|
|
176
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Actor not found: {actor_name}'})}")
|
|
177
|
+
else:
|
|
178
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'EditorActorSubsystem not available'})}")
|
|
179
|
+
except Exception as e:
|
|
180
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
117
181
|
`.trim()
|
|
118
182
|
},
|
|
119
183
|
CREATE_ASSET: {
|
|
120
184
|
name: 'create_asset',
|
|
121
185
|
script: `
|
|
122
186
|
import unreal
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
187
|
+
import json
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
|
191
|
+
if asset_tools:
|
|
192
|
+
# Create factory based on asset type
|
|
193
|
+
factory_class = getattr(unreal, '{factory_class}', None)
|
|
194
|
+
asset_class = getattr(unreal, '{asset_class}', None)
|
|
195
|
+
|
|
196
|
+
if factory_class and asset_class:
|
|
197
|
+
factory = factory_class()
|
|
198
|
+
# Clean up the path - remove trailing slashes and normalize
|
|
199
|
+
package_path = "{package_path}".rstrip('/').replace('//', '/')
|
|
200
|
+
|
|
201
|
+
# Ensure package path is valid (starts with /Game or /Engine)
|
|
202
|
+
if not package_path.startswith('/Game') and not package_path.startswith('/Engine'):
|
|
203
|
+
if not package_path.startswith('/'):
|
|
204
|
+
package_path = f"/Game/{package_path}"
|
|
205
|
+
else:
|
|
206
|
+
package_path = f"/Game{package_path}"
|
|
207
|
+
|
|
208
|
+
# Create full asset path for verification
|
|
209
|
+
full_asset_path = f"{package_path}/{asset_name}" if package_path != "/Game" else f"/Game/{asset_name}"
|
|
210
|
+
|
|
211
|
+
# Create the asset with cleaned path
|
|
212
|
+
asset = asset_tools.create_asset("{asset_name}", package_path, asset_class, factory)
|
|
213
|
+
if asset:
|
|
214
|
+
# Save the asset
|
|
215
|
+
saved = unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
|
216
|
+
# Enhanced verification with retry logic
|
|
217
|
+
asset_path = asset.get_path_name()
|
|
218
|
+
verification_attempts = 0
|
|
219
|
+
max_verification_attempts = 5
|
|
220
|
+
asset_verified = False
|
|
221
|
+
|
|
222
|
+
while verification_attempts < max_verification_attempts and not asset_verified:
|
|
223
|
+
verification_attempts += 1
|
|
224
|
+
# Wait a bit for the asset to be fully saved
|
|
225
|
+
import time
|
|
226
|
+
time.sleep(0.1)
|
|
227
|
+
|
|
228
|
+
# Check if asset exists
|
|
229
|
+
asset_exists = unreal.EditorAssetLibrary.does_asset_exist(asset_path)
|
|
230
|
+
|
|
231
|
+
if asset_exists:
|
|
232
|
+
asset_verified = True
|
|
233
|
+
elif verification_attempts < max_verification_attempts:
|
|
234
|
+
# Try to reload the asset registry
|
|
235
|
+
try:
|
|
236
|
+
unreal.AssetRegistryHelpers.get_asset_registry().scan_modified_asset_files([asset_path])
|
|
237
|
+
except:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
if asset_verified:
|
|
241
|
+
print(f"RESULT:{json.dumps({'success': saved, 'path': asset_path, 'verified': True})}")
|
|
242
|
+
else:
|
|
243
|
+
print(f"RESULT:{json.dumps({'success': saved, 'path': asset_path, 'warning': 'Asset created but verification pending'})}")
|
|
244
|
+
else:
|
|
245
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Failed to create asset'})}")
|
|
246
|
+
else:
|
|
247
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'Invalid factory or asset class'})}")
|
|
248
|
+
else:
|
|
249
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'AssetToolsHelpers not available'})}")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
131
252
|
`.trim()
|
|
132
253
|
},
|
|
133
254
|
SET_VIEWPORT_CAMERA: {
|
|
134
255
|
name: 'set_viewport_camera',
|
|
135
256
|
script: `
|
|
136
257
|
import unreal
|
|
258
|
+
import json
|
|
259
|
+
|
|
137
260
|
location = unreal.Vector({x}, {y}, {z})
|
|
138
261
|
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
# Use UnrealEditorSubsystem for viewport operations (UE5.1+)
|
|
265
|
+
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
266
|
+
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
267
|
+
|
|
268
|
+
if ues:
|
|
269
|
+
ues.set_level_viewport_camera_info(location, rotation)
|
|
270
|
+
try:
|
|
271
|
+
if les:
|
|
272
|
+
les.editor_invalidate_viewports()
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
print(f"RESULT:{json.dumps({'success': True, 'location': [{x}, {y}, {z}], 'rotation': [{pitch}, {yaw}, {roll}]}})}")
|
|
276
|
+
else:
|
|
277
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'UnrealEditorSubsystem not available'})}")
|
|
278
|
+
except Exception as e:
|
|
279
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
152
280
|
`.trim()
|
|
153
281
|
},
|
|
154
282
|
BUILD_LIGHTING: {
|
|
155
283
|
name: 'build_lighting',
|
|
156
284
|
script: `
|
|
157
285
|
import unreal
|
|
286
|
+
import json
|
|
287
|
+
|
|
158
288
|
try:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
289
|
+
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
290
|
+
if les:
|
|
291
|
+
# Use UE 5.6 enhanced lighting quality settings
|
|
292
|
+
quality_map = {
|
|
293
|
+
'Preview': unreal.LightingBuildQuality.PREVIEW,
|
|
294
|
+
'Medium': unreal.LightingBuildQuality.MEDIUM,
|
|
295
|
+
'High': unreal.LightingBuildQuality.HIGH,
|
|
296
|
+
'Production': unreal.LightingBuildQuality.PRODUCTION
|
|
297
|
+
}
|
|
298
|
+
q = quality_map.get('{quality}', unreal.LightingBuildQuality.PREVIEW)
|
|
299
|
+
les.build_light_maps(q, True)
|
|
300
|
+
print(f"RESULT:{json.dumps({'success': True, 'quality': '{quality}', 'method': 'LevelEditorSubsystem'})}")
|
|
301
|
+
else:
|
|
302
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': 'LevelEditorSubsystem not available'})}")
|
|
166
303
|
except Exception as e:
|
|
167
|
-
|
|
304
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e)})}")
|
|
168
305
|
`.trim()
|
|
169
306
|
},
|
|
170
307
|
SAVE_ALL_DIRTY_PACKAGES: {
|
|
171
308
|
name: 'save_dirty_packages',
|
|
172
309
|
script: `
|
|
173
310
|
import unreal
|
|
174
|
-
|
|
175
|
-
|
|
311
|
+
import json
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
# Use UE 5.6 enhanced saving with better error handling
|
|
315
|
+
saved = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
|
316
|
+
print(f"RESULT:{json.dumps({'success': bool(saved), 'saved_count': saved if isinstance(saved, int) else 0, 'message': 'All dirty packages saved'})}")
|
|
317
|
+
except Exception as e:
|
|
318
|
+
print(f"RESULT:{json.dumps({'success': False, 'error': str(e), 'message': 'Failed to save dirty packages'})}")
|
|
176
319
|
`.trim()
|
|
177
320
|
}
|
|
178
321
|
};
|
|
@@ -357,16 +500,68 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
357
500
|
const started = Date.now();
|
|
358
501
|
|
|
359
502
|
// Fix Content-Length header issue - ensure body is properly handled
|
|
360
|
-
|
|
361
|
-
|
|
503
|
+
let payload = body;
|
|
504
|
+
if ((payload === undefined || payload === null) && method !== 'GET') {
|
|
505
|
+
payload = {};
|
|
362
506
|
}
|
|
363
507
|
|
|
364
|
-
// Add timeout wrapper to prevent hanging
|
|
365
|
-
|
|
508
|
+
// Add timeout wrapper to prevent hanging - adjust based on operation type
|
|
509
|
+
let CALL_TIMEOUT = 10000; // Default 10 seconds timeout
|
|
510
|
+
const longRunningTimeout = 10 * 60 * 1000; // 10 minutes for heavy editor jobs
|
|
511
|
+
|
|
512
|
+
// Use payload contents to detect long-running editor operations
|
|
513
|
+
let payloadSignature = '';
|
|
514
|
+
if (typeof payload === 'string') {
|
|
515
|
+
payloadSignature = payload;
|
|
516
|
+
} else if (payload && typeof payload === 'object') {
|
|
517
|
+
try {
|
|
518
|
+
payloadSignature = JSON.stringify(payload);
|
|
519
|
+
} catch {
|
|
520
|
+
payloadSignature = '';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Allow explicit override via meta property when provided
|
|
525
|
+
let sanitizedPayload = payload;
|
|
526
|
+
if (payload && typeof payload === 'object' && '__callTimeoutMs' in payload) {
|
|
527
|
+
const overrideRaw = (payload as any).__callTimeoutMs;
|
|
528
|
+
const overrideMs = typeof overrideRaw === 'number'
|
|
529
|
+
? overrideRaw
|
|
530
|
+
: Number.parseInt(String(overrideRaw), 10);
|
|
531
|
+
if (Number.isFinite(overrideMs) && overrideMs > 0) {
|
|
532
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, overrideMs);
|
|
533
|
+
}
|
|
534
|
+
sanitizedPayload = { ...(payload as any) };
|
|
535
|
+
delete (sanitizedPayload as any).__callTimeoutMs;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// For heavy operations, use longer timeout based on URL or payload signature
|
|
539
|
+
if (url.includes('build') || url.includes('create') || url.includes('asset')) {
|
|
540
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, 30000); // 30 seconds for heavy operations
|
|
541
|
+
}
|
|
542
|
+
if (url.includes('light') || url.includes('BuildLighting')) {
|
|
543
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, 60000); // Base 60 seconds for lighting builds
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (payloadSignature) {
|
|
547
|
+
const longRunningPatterns = [
|
|
548
|
+
/build_light_maps/i,
|
|
549
|
+
/lightingbuildquality/i,
|
|
550
|
+
/editorbuildlibrary/i,
|
|
551
|
+
/buildlighting/i,
|
|
552
|
+
/"command"\s*:\s*"buildlighting/i
|
|
553
|
+
];
|
|
554
|
+
if (longRunningPatterns.some(pattern => pattern.test(payloadSignature))) {
|
|
555
|
+
if (CALL_TIMEOUT < longRunningTimeout) {
|
|
556
|
+
this.log.debug(`Detected long-running lighting operation, extending HTTP timeout to ${longRunningTimeout}ms`);
|
|
557
|
+
}
|
|
558
|
+
CALL_TIMEOUT = Math.max(CALL_TIMEOUT, longRunningTimeout);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
366
561
|
|
|
367
562
|
// CRITICAL: Intercept and block dangerous console commands at HTTP level
|
|
368
|
-
if (url === '/remote/object/call' &&
|
|
369
|
-
const command =
|
|
563
|
+
if (url === '/remote/object/call' && (payload as any)?.functionName === 'ExecuteConsoleCommand') {
|
|
564
|
+
const command = (payload as any)?.parameters?.Command;
|
|
370
565
|
if (command && typeof command === 'string') {
|
|
371
566
|
const cmdLower = command.trim().toLowerCase();
|
|
372
567
|
|
|
@@ -411,23 +606,37 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
411
606
|
try {
|
|
412
607
|
// For GET requests, send payload as query parameters (not in body)
|
|
413
608
|
const config: any = { url, method, timeout: CALL_TIMEOUT };
|
|
414
|
-
if (method === 'GET' &&
|
|
415
|
-
config.params =
|
|
416
|
-
} else if (
|
|
417
|
-
config.data =
|
|
609
|
+
if (method === 'GET' && sanitizedPayload && typeof sanitizedPayload === 'object') {
|
|
610
|
+
config.params = sanitizedPayload;
|
|
611
|
+
} else if (sanitizedPayload !== undefined) {
|
|
612
|
+
config.data = sanitizedPayload;
|
|
418
613
|
}
|
|
419
|
-
|
|
614
|
+
|
|
420
615
|
// Wrap with timeout promise to ensure we don't hang
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
setTimeout(() => {
|
|
424
|
-
|
|
616
|
+
const requestPromise = this.http.request<T>(config);
|
|
617
|
+
const resp = await new Promise<Awaited<typeof requestPromise>>((resolve, reject) => {
|
|
618
|
+
const timer = setTimeout(() => {
|
|
619
|
+
const err = new Error(`Request timeout after ${CALL_TIMEOUT}ms`);
|
|
620
|
+
(err as any).code = 'UE_HTTP_TIMEOUT';
|
|
621
|
+
reject(err);
|
|
425
622
|
}, CALL_TIMEOUT);
|
|
623
|
+
requestPromise.then(result => {
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
resolve(result);
|
|
626
|
+
}).catch(err => {
|
|
627
|
+
clearTimeout(timer);
|
|
628
|
+
reject(err);
|
|
629
|
+
});
|
|
426
630
|
});
|
|
427
|
-
|
|
428
|
-
const resp = await Promise.race([requestPromise, timeoutPromise]);
|
|
429
631
|
const ms = Date.now() - started;
|
|
430
|
-
|
|
632
|
+
|
|
633
|
+
// Add connection health check for long-running requests
|
|
634
|
+
if (ms > 5000) {
|
|
635
|
+
this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms (long request)`);
|
|
636
|
+
} else {
|
|
637
|
+
this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms`);
|
|
638
|
+
}
|
|
639
|
+
|
|
431
640
|
return resp.data;
|
|
432
641
|
} catch (error: any) {
|
|
433
642
|
lastError = error;
|
|
@@ -455,6 +664,159 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
455
664
|
throw lastError;
|
|
456
665
|
}
|
|
457
666
|
|
|
667
|
+
private parsePythonJsonResult<T = any>(raw: any): T | null {
|
|
668
|
+
if (!raw) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const fragments: string[] = [];
|
|
673
|
+
|
|
674
|
+
if (typeof raw === 'string') {
|
|
675
|
+
fragments.push(raw);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (typeof raw?.Output === 'string') {
|
|
679
|
+
fragments.push(raw.Output);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (typeof raw?.ReturnValue === 'string') {
|
|
683
|
+
fragments.push(raw.ReturnValue);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (Array.isArray(raw?.LogOutput)) {
|
|
687
|
+
for (const entry of raw.LogOutput) {
|
|
688
|
+
if (!entry) continue;
|
|
689
|
+
if (typeof entry === 'string') {
|
|
690
|
+
fragments.push(entry);
|
|
691
|
+
} else if (typeof entry?.Output === 'string') {
|
|
692
|
+
fragments.push(entry.Output);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const combined = fragments.join('\n');
|
|
698
|
+
const match = combined.match(/RESULT:(\{.*\}|\[.*\])/s);
|
|
699
|
+
if (!match) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
return JSON.parse(match[1]);
|
|
705
|
+
} catch {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async ensurePluginsEnabled(pluginNames: string[], context?: string): Promise<string[]> {
|
|
711
|
+
if (!pluginNames || pluginNames.length === 0) {
|
|
712
|
+
return [];
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const now = Date.now();
|
|
716
|
+
const pluginsToCheck = pluginNames.filter((name) => {
|
|
717
|
+
const cached = this.pluginStatusCache.get(name);
|
|
718
|
+
if (!cached) return true;
|
|
719
|
+
if (now - cached.timestamp > this.PLUGIN_CACHE_TTL) {
|
|
720
|
+
this.pluginStatusCache.delete(name);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
return false;
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (pluginsToCheck.length > 0) {
|
|
727
|
+
const python = `
|
|
728
|
+
import unreal
|
|
729
|
+
import json
|
|
730
|
+
|
|
731
|
+
plugins = ${JSON.stringify(pluginsToCheck)}
|
|
732
|
+
status = {}
|
|
733
|
+
|
|
734
|
+
def get_plugin_manager():
|
|
735
|
+
try:
|
|
736
|
+
return unreal.PluginManager.get()
|
|
737
|
+
except AttributeError:
|
|
738
|
+
return None
|
|
739
|
+
except Exception:
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
def get_plugins_subsystem():
|
|
743
|
+
try:
|
|
744
|
+
return unreal.get_editor_subsystem(unreal.PluginsEditorSubsystem)
|
|
745
|
+
except AttributeError:
|
|
746
|
+
pass
|
|
747
|
+
except Exception:
|
|
748
|
+
pass
|
|
749
|
+
try:
|
|
750
|
+
return unreal.PluginsSubsystem()
|
|
751
|
+
except Exception:
|
|
752
|
+
return None
|
|
753
|
+
|
|
754
|
+
pm = get_plugin_manager()
|
|
755
|
+
ps = get_plugins_subsystem()
|
|
756
|
+
|
|
757
|
+
def is_enabled(plugin_name):
|
|
758
|
+
if pm:
|
|
759
|
+
try:
|
|
760
|
+
if pm.is_plugin_enabled(plugin_name):
|
|
761
|
+
return True
|
|
762
|
+
except Exception:
|
|
763
|
+
try:
|
|
764
|
+
plugin = pm.find_plugin(plugin_name)
|
|
765
|
+
if plugin and plugin.is_enabled():
|
|
766
|
+
return True
|
|
767
|
+
except Exception:
|
|
768
|
+
pass
|
|
769
|
+
if ps:
|
|
770
|
+
try:
|
|
771
|
+
return bool(ps.is_plugin_enabled(plugin_name))
|
|
772
|
+
except Exception:
|
|
773
|
+
try:
|
|
774
|
+
plugin = ps.find_plugin(plugin_name)
|
|
775
|
+
if plugin and plugin.is_enabled():
|
|
776
|
+
return True
|
|
777
|
+
except Exception:
|
|
778
|
+
pass
|
|
779
|
+
return False
|
|
780
|
+
|
|
781
|
+
for plugin_name in plugins:
|
|
782
|
+
enabled = False
|
|
783
|
+
try:
|
|
784
|
+
enabled = is_enabled(plugin_name)
|
|
785
|
+
except Exception:
|
|
786
|
+
enabled = False
|
|
787
|
+
status[plugin_name] = bool(enabled)
|
|
788
|
+
|
|
789
|
+
print('RESULT:' + json.dumps(status))
|
|
790
|
+
`.trim();
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
const response = await this.executePython(python);
|
|
794
|
+
const parsed = this.parsePythonJsonResult<Record<string, boolean>>(response);
|
|
795
|
+
if (parsed) {
|
|
796
|
+
for (const [name, enabled] of Object.entries(parsed)) {
|
|
797
|
+
this.pluginStatusCache.set(name, { enabled: Boolean(enabled), timestamp: now });
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
this.log.warn('Failed to parse plugin status response', { context, pluginsToCheck });
|
|
801
|
+
}
|
|
802
|
+
} catch (error) {
|
|
803
|
+
this.log.warn('Plugin status check failed', { context, pluginsToCheck, error: (error as Error)?.message ?? error });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
for (const name of pluginNames) {
|
|
808
|
+
if (!this.pluginStatusCache.has(name)) {
|
|
809
|
+
this.pluginStatusCache.set(name, { enabled: false, timestamp: now });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const missing = pluginNames.filter((name) => !this.pluginStatusCache.get(name)?.enabled);
|
|
814
|
+
if (missing.length && context) {
|
|
815
|
+
this.log.warn(`Missing required Unreal plugins for ${context}: ${missing.join(', ')}`);
|
|
816
|
+
}
|
|
817
|
+
return missing;
|
|
818
|
+
}
|
|
819
|
+
|
|
458
820
|
// Generic function call via Remote Control HTTP API
|
|
459
821
|
async call(body: RcCallBody): Promise<any> {
|
|
460
822
|
if (!this.connected) throw new Error('Not connected to Unreal Engine');
|
|
@@ -472,10 +834,11 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
472
834
|
}
|
|
473
835
|
|
|
474
836
|
// Execute a console command safely with validation and throttling
|
|
475
|
-
async executeConsoleCommand(command: string): Promise<any> {
|
|
837
|
+
async executeConsoleCommand(command: string, options: { allowPython?: boolean } = {}): Promise<any> {
|
|
476
838
|
if (!this.connected) {
|
|
477
839
|
throw new Error('Not connected to Unreal Engine');
|
|
478
840
|
}
|
|
841
|
+
const { allowPython = false } = options;
|
|
479
842
|
// Validate command is not empty
|
|
480
843
|
if (!command || typeof command !== 'string') {
|
|
481
844
|
throw new Error('Invalid command: must be a non-empty string');
|
|
@@ -486,6 +849,16 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
486
849
|
// Return success for empty commands to match UE behavior
|
|
487
850
|
return { success: true, message: 'Empty command ignored' };
|
|
488
851
|
}
|
|
852
|
+
|
|
853
|
+
if (cmdTrimmed.includes('\n') || cmdTrimmed.includes('\r')) {
|
|
854
|
+
throw new Error('Multi-line console commands are not allowed. Send one command per call.');
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const cmdLower = cmdTrimmed.toLowerCase();
|
|
858
|
+
|
|
859
|
+
if (!allowPython && (cmdLower === 'py' || cmdLower.startsWith('py '))) {
|
|
860
|
+
throw new Error('Python console commands are blocked from external calls for safety.');
|
|
861
|
+
}
|
|
489
862
|
|
|
490
863
|
// Check for dangerous commands
|
|
491
864
|
const dangerousCommands = [
|
|
@@ -496,11 +869,25 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
496
869
|
'buildpaths', // Can cause access violation if nav system not initialized
|
|
497
870
|
'rebuildnavigation' // Can also crash without proper nav setup
|
|
498
871
|
];
|
|
499
|
-
|
|
500
|
-
const cmdLower = cmdTrimmed.toLowerCase();
|
|
501
872
|
if (dangerousCommands.some(dangerous => cmdLower.includes(dangerous))) {
|
|
502
873
|
throw new Error(`Dangerous command blocked: ${command}`);
|
|
503
874
|
}
|
|
875
|
+
|
|
876
|
+
const forbiddenTokens = [
|
|
877
|
+
'rm ', 'rm-', 'del ', 'format ', 'shutdown', 'reboot',
|
|
878
|
+
'rmdir', 'mklink', 'copy ', 'move ', 'start "', 'system(',
|
|
879
|
+
'import os', 'import subprocess', 'subprocess.', 'os.system',
|
|
880
|
+
'exec(', 'eval(', '__import__', 'import sys', 'import importlib',
|
|
881
|
+
'with open', 'open('
|
|
882
|
+
];
|
|
883
|
+
|
|
884
|
+
if (cmdLower.includes('&&') || cmdLower.includes('||')) {
|
|
885
|
+
throw new Error('Command chaining with && or || is blocked for safety.');
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (forbiddenTokens.some(token => cmdLower.includes(token))) {
|
|
889
|
+
throw new Error(`Command contains unsafe token and was blocked: ${command}`);
|
|
890
|
+
}
|
|
504
891
|
|
|
505
892
|
// Determine priority based on command type
|
|
506
893
|
let priority = 7; // Default priority
|
|
@@ -547,6 +934,82 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
547
934
|
}
|
|
548
935
|
}
|
|
549
936
|
|
|
937
|
+
summarizeConsoleCommand(command: string, response: any) {
|
|
938
|
+
const trimmedCommand = command.trim();
|
|
939
|
+
const logLines = Array.isArray(response?.LogOutput)
|
|
940
|
+
? (response.LogOutput as any[]).map(entry => {
|
|
941
|
+
if (entry === null || entry === undefined) {
|
|
942
|
+
return '';
|
|
943
|
+
}
|
|
944
|
+
if (typeof entry === 'string') {
|
|
945
|
+
return entry;
|
|
946
|
+
}
|
|
947
|
+
return typeof entry.Output === 'string' ? entry.Output : '';
|
|
948
|
+
}).filter(Boolean)
|
|
949
|
+
: [];
|
|
950
|
+
|
|
951
|
+
let output = logLines.join('\n').trim();
|
|
952
|
+
if (!output) {
|
|
953
|
+
if (typeof response === 'string') {
|
|
954
|
+
output = response.trim();
|
|
955
|
+
} else if (response && typeof response === 'object') {
|
|
956
|
+
if (typeof response.Output === 'string') {
|
|
957
|
+
output = response.Output.trim();
|
|
958
|
+
} else if ('result' in response && response.result !== undefined) {
|
|
959
|
+
output = String(response.result).trim();
|
|
960
|
+
} else if ('ReturnValue' in response && typeof response.ReturnValue === 'string') {
|
|
961
|
+
output = response.ReturnValue.trim();
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const returnValue = response && typeof response === 'object' && 'ReturnValue' in response
|
|
967
|
+
? (response as any).ReturnValue
|
|
968
|
+
: undefined;
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
command: trimmedCommand,
|
|
972
|
+
output,
|
|
973
|
+
logLines,
|
|
974
|
+
returnValue,
|
|
975
|
+
raw: response
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async executeConsoleCommands(
|
|
980
|
+
commands: Iterable<string | { command: string; priority?: number; allowPython?: boolean }>,
|
|
981
|
+
options: { continueOnError?: boolean; delayMs?: number } = {}
|
|
982
|
+
): Promise<any[]> {
|
|
983
|
+
const { continueOnError = false, delayMs = 0 } = options;
|
|
984
|
+
const results: any[] = [];
|
|
985
|
+
|
|
986
|
+
for (const rawCommand of commands) {
|
|
987
|
+
const descriptor = typeof rawCommand === 'string' ? { command: rawCommand } : rawCommand;
|
|
988
|
+
const command = descriptor.command?.trim();
|
|
989
|
+
if (!command) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
const result = await this.executeConsoleCommand(command, {
|
|
994
|
+
allowPython: Boolean(descriptor.allowPython)
|
|
995
|
+
});
|
|
996
|
+
results.push(result);
|
|
997
|
+
} catch (error) {
|
|
998
|
+
if (!continueOnError) {
|
|
999
|
+
throw error;
|
|
1000
|
+
}
|
|
1001
|
+
this.log.warn(`Console batch command failed: ${command}`, error);
|
|
1002
|
+
results.push(error);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (delayMs > 0) {
|
|
1006
|
+
await this.delay(delayMs);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
return results;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
550
1013
|
// Try to execute a Python command via the PythonScriptPlugin, fallback to `py` console command.
|
|
551
1014
|
async executePython(command: string): Promise<any> {
|
|
552
1015
|
if (!this.connected) {
|
|
@@ -582,7 +1045,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
582
1045
|
|
|
583
1046
|
// For simple single-line commands
|
|
584
1047
|
if (!isMultiLine) {
|
|
585
|
-
return await this.executeConsoleCommand(`py ${command}
|
|
1048
|
+
return await this.executeConsoleCommand(`py ${command}`, { allowPython: true });
|
|
586
1049
|
}
|
|
587
1050
|
|
|
588
1051
|
// For multi-line scripts, try to execute as a block
|
|
@@ -594,7 +1057,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
594
1057
|
.replace(/"/g, '\\"')
|
|
595
1058
|
.replace(/\n/g, '\\n')
|
|
596
1059
|
.replace(/\r/g, '');
|
|
597
|
-
return await this.executeConsoleCommand(`py exec("${escapedScript}")
|
|
1060
|
+
return await this.executeConsoleCommand(`py exec("${escapedScript}")`, { allowPython: true });
|
|
598
1061
|
} catch {
|
|
599
1062
|
// If that fails, break into smaller chunks
|
|
600
1063
|
try {
|
|
@@ -616,9 +1079,9 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
616
1079
|
if (stmt.length > 200) {
|
|
617
1080
|
// Try to execute as a single exec block
|
|
618
1081
|
const miniScript = `exec("""${stmt.replace(/"/g, '\\"')}""")`;
|
|
619
|
-
result = await this.executeConsoleCommand(`py ${miniScript}
|
|
1082
|
+
result = await this.executeConsoleCommand(`py ${miniScript}`, { allowPython: true });
|
|
620
1083
|
} else {
|
|
621
|
-
result = await this.executeConsoleCommand(`py ${stmt}
|
|
1084
|
+
result = await this.executeConsoleCommand(`py ${stmt}`, { allowPython: true });
|
|
622
1085
|
}
|
|
623
1086
|
// Small delay between commands
|
|
624
1087
|
await new Promise(resolve => setTimeout(resolve, 30));
|
|
@@ -635,7 +1098,7 @@ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
|
635
1098
|
if (line.trim().startsWith('#')) {
|
|
636
1099
|
continue;
|
|
637
1100
|
}
|
|
638
|
-
result = await this.executeConsoleCommand(`py ${line.trim()}
|
|
1101
|
+
result = await this.executeConsoleCommand(`py ${line.trim()}`, { allowPython: true });
|
|
639
1102
|
// Small delay between commands to ensure execution order
|
|
640
1103
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
641
1104
|
}
|
|
@@ -834,6 +1297,11 @@ finally:
|
|
|
834
1297
|
* Get the Unreal Engine version via Python and parse major/minor/patch.
|
|
835
1298
|
*/
|
|
836
1299
|
async getEngineVersion(): Promise<{ version: string; major: number; minor: number; patch: number; isUE56OrAbove: boolean; }> {
|
|
1300
|
+
const now = Date.now();
|
|
1301
|
+
if (this.engineVersionCache && now - this.engineVersionCache.timestamp < this.ENGINE_VERSION_TTL_MS) {
|
|
1302
|
+
return this.engineVersionCache.value;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
837
1305
|
try {
|
|
838
1306
|
const script = `
|
|
839
1307
|
import unreal, json, re
|
|
@@ -850,10 +1318,14 @@ print('RESULT:' + json.dumps({'version': ver, 'major': major, 'minor': minor, 'p
|
|
|
850
1318
|
const minor = Number(result?.minor ?? 0) || 0;
|
|
851
1319
|
const patch = Number(result?.patch ?? 0) || 0;
|
|
852
1320
|
const isUE56OrAbove = major > 5 || (major === 5 && minor >= 6);
|
|
853
|
-
|
|
1321
|
+
const value = { version, major, minor, patch, isUE56OrAbove };
|
|
1322
|
+
this.engineVersionCache = { value, timestamp: now };
|
|
1323
|
+
return value;
|
|
854
1324
|
} catch (error) {
|
|
855
1325
|
this.log.warn('Failed to get engine version via Python', error);
|
|
856
|
-
|
|
1326
|
+
const fallback = { version: 'unknown', major: 0, minor: 0, patch: 0, isUE56OrAbove: false };
|
|
1327
|
+
this.engineVersionCache = { value: fallback, timestamp: now };
|
|
1328
|
+
return fallback;
|
|
857
1329
|
}
|
|
858
1330
|
}
|
|
859
1331
|
|
|
@@ -928,45 +1400,71 @@ print('RESULT:' + json.dumps(flags))
|
|
|
928
1400
|
* Prevent crashes by validating and safely switching viewmodes
|
|
929
1401
|
*/
|
|
930
1402
|
async setSafeViewMode(mode: string): Promise<any> {
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
// First ensure we're in a safe state
|
|
940
|
-
await this.executeConsoleCommand('viewmode Lit');
|
|
941
|
-
await this.delay(100);
|
|
942
|
-
|
|
943
|
-
// Try to use a safer alternative or skip
|
|
944
|
-
this.log.info(`Skipping unsafe visualizeBuffer mode: ${normalizedMode}`);
|
|
945
|
-
return {
|
|
946
|
-
success: false,
|
|
947
|
-
message: `Viewmode ${normalizedMode} skipped for safety`,
|
|
948
|
-
alternative: 'Lit'
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// For other unsafe modes, switch to safe alternatives
|
|
953
|
-
const safeAlternative = this.getSafeAlternative(normalizedMode);
|
|
954
|
-
return this.executeConsoleCommand(`viewmode ${safeAlternative}`);
|
|
1403
|
+
const acceptedModes = Array.from(new Set(this.VIEWMODE_ALIASES.values())).sort();
|
|
1404
|
+
|
|
1405
|
+
if (typeof mode !== 'string') {
|
|
1406
|
+
return {
|
|
1407
|
+
success: false,
|
|
1408
|
+
error: 'View mode must be provided as a string',
|
|
1409
|
+
acceptedModes
|
|
1410
|
+
};
|
|
955
1411
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1412
|
+
|
|
1413
|
+
const key = mode.trim().toLowerCase().replace(/[\s_-]+/g, '');
|
|
1414
|
+
if (!key) {
|
|
1415
|
+
return {
|
|
1416
|
+
success: false,
|
|
1417
|
+
error: 'View mode cannot be empty',
|
|
1418
|
+
acceptedModes
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const targetMode = this.VIEWMODE_ALIASES.get(key);
|
|
1423
|
+
if (!targetMode) {
|
|
1424
|
+
return {
|
|
1425
|
+
success: false,
|
|
1426
|
+
error: `Unknown view mode '${mode}'`,
|
|
1427
|
+
acceptedModes
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (this.HARD_BLOCKED_VIEWMODES.has(targetMode)) {
|
|
1432
|
+
this.log.warn(`Viewmode '${targetMode}' is blocked for safety. Using alternative.`);
|
|
1433
|
+
const alternative = this.getSafeAlternative(targetMode);
|
|
1434
|
+
const altCommand = `viewmode ${alternative}`;
|
|
1435
|
+
const altResult = await this.executeConsoleCommand(altCommand);
|
|
1436
|
+
const altSummary = this.summarizeConsoleCommand(altCommand, altResult);
|
|
1437
|
+
return {
|
|
1438
|
+
...altSummary,
|
|
1439
|
+
success: false,
|
|
1440
|
+
requestedMode: targetMode,
|
|
1441
|
+
viewMode: alternative,
|
|
1442
|
+
message: `View mode '${targetMode}' is unsafe in remote sessions. Switched to '${alternative}'.`,
|
|
1443
|
+
alternative
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const command = `viewmode ${targetMode}`;
|
|
1448
|
+
const rawResult = await this.executeConsoleCommand(command);
|
|
1449
|
+
const summary = this.summarizeConsoleCommand(command, rawResult);
|
|
1450
|
+
const response: any = {
|
|
1451
|
+
...summary,
|
|
1452
|
+
success: summary.returnValue !== false,
|
|
1453
|
+
requestedMode: targetMode,
|
|
1454
|
+
viewMode: targetMode,
|
|
1455
|
+
message: `View mode set to ${targetMode}`
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
if (this.UNSAFE_VIEWMODES.includes(targetMode)) {
|
|
1459
|
+
response.warning = `View mode '${targetMode}' may be unstable on some engine versions.`;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (summary.output && /unknown|invalid/i.test(summary.output)) {
|
|
1463
|
+
response.success = false;
|
|
1464
|
+
response.error = summary.output;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
return response;
|
|
970
1468
|
}
|
|
971
1469
|
|
|
972
1470
|
/**
|
|
@@ -979,6 +1477,8 @@ print('RESULT:' + json.dumps(flags))
|
|
|
979
1477
|
'Metallic': 'Lit',
|
|
980
1478
|
'Specular': 'Lit',
|
|
981
1479
|
'Roughness': 'Lit',
|
|
1480
|
+
'SubsurfaceColor': 'Lit',
|
|
1481
|
+
'Opacity': 'Lit',
|
|
982
1482
|
'LightComplexity': 'LightingOnly',
|
|
983
1483
|
'ShaderComplexity': 'Wireframe',
|
|
984
1484
|
'CollisionPawn': 'Wireframe',
|
|
@@ -1097,7 +1597,10 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1097
1597
|
}
|
|
1098
1598
|
// Priority 7,9-10: Light operations (console commands, queries)
|
|
1099
1599
|
else {
|
|
1100
|
-
|
|
1600
|
+
// For light operations, add some jitter to prevent thundering herd
|
|
1601
|
+
const baseDelay = this.MIN_COMMAND_DELAY;
|
|
1602
|
+
const jitter = Math.random() * 50; // Add up to 50ms random jitter
|
|
1603
|
+
return baseDelay + jitter;
|
|
1101
1604
|
}
|
|
1102
1605
|
}
|
|
1103
1606
|
|
|
@@ -1144,6 +1647,28 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1144
1647
|
this.processCommandQueue();
|
|
1145
1648
|
}
|
|
1146
1649
|
}, 1000);
|
|
1650
|
+
|
|
1651
|
+
// Clean console cache every 5 minutes
|
|
1652
|
+
setInterval(() => {
|
|
1653
|
+
this.cleanConsoleCache();
|
|
1654
|
+
}, this.CONSOLE_CACHE_TTL);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Clean expired entries from console object cache
|
|
1659
|
+
*/
|
|
1660
|
+
private cleanConsoleCache(): void {
|
|
1661
|
+
const now = Date.now();
|
|
1662
|
+
let cleaned = 0;
|
|
1663
|
+
for (const [key, value] of this.consoleObjectCache.entries()) {
|
|
1664
|
+
if (now - (value.timestamp || 0) > this.CONSOLE_CACHE_TTL) {
|
|
1665
|
+
this.consoleObjectCache.delete(key);
|
|
1666
|
+
cleaned++;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (cleaned > 0) {
|
|
1670
|
+
this.log.debug(`Cleaned ${cleaned} expired console cache entries`);
|
|
1671
|
+
}
|
|
1147
1672
|
}
|
|
1148
1673
|
|
|
1149
1674
|
/**
|
|
@@ -1157,14 +1682,7 @@ print('RESULT:' + json.dumps(flags))
|
|
|
1157
1682
|
* Batch command execution with proper delays
|
|
1158
1683
|
*/
|
|
1159
1684
|
async executeBatch(commands: Array<{ command: string; priority?: number }>): Promise<any[]> {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
for (const cmd of commands) {
|
|
1163
|
-
const result = await this.executeConsoleCommand(cmd.command);
|
|
1164
|
-
results.push(result);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
return results;
|
|
1685
|
+
return this.executeConsoleCommands(commands.map(cmd => cmd.command));
|
|
1168
1686
|
}
|
|
1169
1687
|
|
|
1170
1688
|
/**
|