unreal-engine-mcp-server 0.3.1 → 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.
Files changed (144) hide show
  1. package/.env.production +1 -1
  2. package/.github/copilot-instructions.md +45 -0
  3. package/.github/workflows/publish-mcp.yml +1 -1
  4. package/README.md +22 -7
  5. package/dist/index.js +137 -46
  6. package/dist/prompts/index.d.ts +10 -3
  7. package/dist/prompts/index.js +186 -7
  8. package/dist/resources/actors.d.ts +19 -1
  9. package/dist/resources/actors.js +55 -64
  10. package/dist/resources/assets.d.ts +3 -2
  11. package/dist/resources/assets.js +117 -109
  12. package/dist/resources/levels.d.ts +21 -3
  13. package/dist/resources/levels.js +31 -56
  14. package/dist/tools/actors.d.ts +3 -14
  15. package/dist/tools/actors.js +246 -302
  16. package/dist/tools/animation.d.ts +57 -102
  17. package/dist/tools/animation.js +429 -450
  18. package/dist/tools/assets.d.ts +13 -2
  19. package/dist/tools/assets.js +58 -46
  20. package/dist/tools/audio.d.ts +22 -13
  21. package/dist/tools/audio.js +467 -121
  22. package/dist/tools/blueprint.d.ts +32 -13
  23. package/dist/tools/blueprint.js +699 -448
  24. package/dist/tools/build_environment_advanced.d.ts +0 -1
  25. package/dist/tools/build_environment_advanced.js +236 -87
  26. package/dist/tools/consolidated-tool-definitions.d.ts +232 -15
  27. package/dist/tools/consolidated-tool-definitions.js +124 -255
  28. package/dist/tools/consolidated-tool-handlers.js +749 -766
  29. package/dist/tools/debug.d.ts +72 -10
  30. package/dist/tools/debug.js +170 -36
  31. package/dist/tools/editor.d.ts +9 -2
  32. package/dist/tools/editor.js +30 -44
  33. package/dist/tools/foliage.d.ts +34 -15
  34. package/dist/tools/foliage.js +97 -107
  35. package/dist/tools/introspection.js +19 -21
  36. package/dist/tools/landscape.d.ts +1 -2
  37. package/dist/tools/landscape.js +311 -168
  38. package/dist/tools/level.d.ts +3 -28
  39. package/dist/tools/level.js +642 -192
  40. package/dist/tools/lighting.d.ts +14 -3
  41. package/dist/tools/lighting.js +236 -123
  42. package/dist/tools/materials.d.ts +25 -7
  43. package/dist/tools/materials.js +102 -79
  44. package/dist/tools/niagara.d.ts +10 -12
  45. package/dist/tools/niagara.js +74 -94
  46. package/dist/tools/performance.d.ts +12 -4
  47. package/dist/tools/performance.js +38 -79
  48. package/dist/tools/physics.d.ts +34 -10
  49. package/dist/tools/physics.js +364 -292
  50. package/dist/tools/rc.js +98 -24
  51. package/dist/tools/sequence.d.ts +1 -0
  52. package/dist/tools/sequence.js +146 -24
  53. package/dist/tools/ui.d.ts +31 -4
  54. package/dist/tools/ui.js +83 -66
  55. package/dist/tools/visual.d.ts +11 -0
  56. package/dist/tools/visual.js +245 -30
  57. package/dist/types/tool-types.d.ts +0 -6
  58. package/dist/types/tool-types.js +1 -8
  59. package/dist/unreal-bridge.d.ts +32 -2
  60. package/dist/unreal-bridge.js +621 -127
  61. package/dist/utils/elicitation.d.ts +57 -0
  62. package/dist/utils/elicitation.js +104 -0
  63. package/dist/utils/error-handler.d.ts +0 -33
  64. package/dist/utils/error-handler.js +4 -111
  65. package/dist/utils/http.d.ts +2 -22
  66. package/dist/utils/http.js +12 -75
  67. package/dist/utils/normalize.d.ts +4 -4
  68. package/dist/utils/normalize.js +15 -7
  69. package/dist/utils/python-output.d.ts +18 -0
  70. package/dist/utils/python-output.js +290 -0
  71. package/dist/utils/python.d.ts +2 -0
  72. package/dist/utils/python.js +4 -0
  73. package/dist/utils/response-validator.d.ts +6 -1
  74. package/dist/utils/response-validator.js +66 -13
  75. package/dist/utils/result-helpers.d.ts +27 -0
  76. package/dist/utils/result-helpers.js +147 -0
  77. package/dist/utils/safe-json.d.ts +0 -2
  78. package/dist/utils/safe-json.js +0 -43
  79. package/dist/utils/validation.d.ts +16 -0
  80. package/dist/utils/validation.js +70 -7
  81. package/mcp-config-example.json +2 -2
  82. package/package.json +11 -10
  83. package/server.json +37 -14
  84. package/src/index.ts +146 -50
  85. package/src/prompts/index.ts +211 -13
  86. package/src/resources/actors.ts +59 -44
  87. package/src/resources/assets.ts +123 -102
  88. package/src/resources/levels.ts +37 -47
  89. package/src/tools/actors.ts +269 -313
  90. package/src/tools/animation.ts +556 -539
  91. package/src/tools/assets.ts +59 -45
  92. package/src/tools/audio.ts +507 -113
  93. package/src/tools/blueprint.ts +778 -462
  94. package/src/tools/build_environment_advanced.ts +312 -106
  95. package/src/tools/consolidated-tool-definitions.ts +136 -267
  96. package/src/tools/consolidated-tool-handlers.ts +871 -795
  97. package/src/tools/debug.ts +179 -38
  98. package/src/tools/editor.ts +35 -37
  99. package/src/tools/foliage.ts +110 -104
  100. package/src/tools/introspection.ts +24 -22
  101. package/src/tools/landscape.ts +334 -181
  102. package/src/tools/level.ts +683 -182
  103. package/src/tools/lighting.ts +244 -123
  104. package/src/tools/materials.ts +114 -83
  105. package/src/tools/niagara.ts +87 -81
  106. package/src/tools/performance.ts +49 -88
  107. package/src/tools/physics.ts +393 -299
  108. package/src/tools/rc.ts +103 -25
  109. package/src/tools/sequence.ts +157 -30
  110. package/src/tools/ui.ts +101 -70
  111. package/src/tools/visual.ts +250 -29
  112. package/src/types/tool-types.ts +0 -9
  113. package/src/unreal-bridge.ts +658 -140
  114. package/src/utils/elicitation.ts +129 -0
  115. package/src/utils/error-handler.ts +4 -159
  116. package/src/utils/http.ts +16 -115
  117. package/src/utils/normalize.ts +20 -10
  118. package/src/utils/python-output.ts +351 -0
  119. package/src/utils/python.ts +3 -0
  120. package/src/utils/response-validator.ts +68 -17
  121. package/src/utils/result-helpers.ts +193 -0
  122. package/src/utils/safe-json.ts +0 -50
  123. package/src/utils/validation.ts +94 -7
  124. package/tests/run-unreal-tool-tests.mjs +720 -0
  125. package/tsconfig.json +2 -2
  126. package/dist/python-utils.d.ts +0 -29
  127. package/dist/python-utils.js +0 -54
  128. package/dist/tools/tool-definitions.d.ts +0 -4919
  129. package/dist/tools/tool-definitions.js +0 -1065
  130. package/dist/tools/tool-handlers.d.ts +0 -47
  131. package/dist/tools/tool-handlers.js +0 -863
  132. package/dist/types/index.d.ts +0 -323
  133. package/dist/types/index.js +0 -28
  134. package/dist/utils/cache-manager.d.ts +0 -64
  135. package/dist/utils/cache-manager.js +0 -176
  136. package/dist/utils/errors.d.ts +0 -133
  137. package/dist/utils/errors.js +0 -256
  138. package/src/python/editor_compat.py +0 -181
  139. package/src/python-utils.ts +0 -57
  140. package/src/tools/tool-definitions.ts +0 -1081
  141. package/src/tools/tool-handlers.ts +0 -973
  142. package/src/types/index.ts +0 -414
  143. package/src/utils/cache-manager.ts +0 -213
  144. package/src/utils/errors.ts +0 -312
@@ -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
- // Safe viewmodes that won't cause crashes (per docs and testing)
55
- private readonly _SAFE_VIEWMODES = [
56
- 'Lit', 'Unlit', 'Wireframe', 'DetailLighting',
57
- 'LightingOnly', 'ReflectionOverride', 'ShaderComplexity'
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
- 'Roughness', 'SubsurfaceColor', 'Opacity',
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
- subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
76
- actors = subsys.get_all_level_actors()
77
- result = [{'name': a.get_name(), 'class': a.get_class().get_name(), 'path': a.get_path_name()} for a in actors]
78
- print(f"RESULT:{result}")
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
- actor_class = unreal.EditorAssetLibrary.load_asset("{class_path}")
88
- if actor_class:
89
- # Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
90
- subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
91
- spawned = subsys.spawn_actor_from_object(actor_class, location, rotation)
92
- print(f"RESULT:{{'success': True, 'actor': spawned.get_name()}}")
93
- else:
94
- print(f"RESULT:{{'success': False, 'error': 'Failed to load actor class'}}")
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
- subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
103
- actors = subsys.get_all_level_actors()
104
- found = False
105
- for actor in actors:
106
- if not actor:
107
- continue
108
- label = actor.get_actor_label()
109
- name = actor.get_name()
110
- if label == "{actor_name}" or name == "{actor_name}" or label.lower().startswith("{actor_name}".lower()+"_"):
111
- subsys.destroy_actor(actor)
112
- print("RESULT:" + json.dumps({'success': True, 'deleted': label}))
113
- found = True
114
- break
115
- if not found:
116
- print("RESULT:" + json.dumps({'success': False, 'error': 'Actor not found'}))
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
- asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
124
- factory = unreal.{factory_class}()
125
- asset = asset_tools.create_asset("{asset_name}", "{package_path}", {asset_class}, factory)
126
- if asset:
127
- unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
128
- print(f"RESULT:{{'success': True, 'path': asset.get_path_name()}}")
129
- else:
130
- print(f"RESULT:{{'success': False, 'error': 'Failed to create asset'}}")
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
- # Use UnrealEditorSubsystem for viewport operations (UE5.1+)
140
- ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
141
- les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
142
- if ues:
143
- ues.set_level_viewport_camera_info(location, rotation)
144
- try:
145
- if les:
146
- les.editor_invalidate_viewports()
147
- except Exception:
148
- pass
149
- print(f"RESULT:{{'success': True, 'location': [{x}, {y}, {z}], 'rotation': [{pitch}, {yaw}, {roll}]}}")
150
- else:
151
- print(f"RESULT:{{'success': False, 'error': 'UnrealEditorSubsystem not available'}}")
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
- les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
160
- if les:
161
- q = unreal.LightingBuildQuality.{quality}
162
- les.build_light_maps(q, True)
163
- print(f"RESULT:{{'success': True, 'quality': '{quality}', 'method': 'LevelEditorSubsystem'}}")
164
- else:
165
- print(f"RESULT:{{'success': False, 'error': 'LevelEditorSubsystem not available'}}")
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
- print(f"RESULT:{{'success': False, 'error': str(e)}}")
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
- saved = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
175
- print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
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
- if (body === undefined || body === null) {
361
- body = method === 'GET' ? undefined : {};
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
- const CALL_TIMEOUT = 10000; // 10 seconds timeout
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' && body?.functionName === 'ExecuteConsoleCommand') {
369
- const command = body?.parameters?.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' && body && typeof body === 'object') {
415
- config.params = body;
416
- } else if (body !== undefined) {
417
- config.data = body;
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
- const requestPromise = this.http.request<T>(config);
422
- const timeoutPromise = new Promise<never>((_, reject) => {
423
- setTimeout(() => {
424
- reject(new Error(`Request timeout after ${CALL_TIMEOUT}ms`));
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
- this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms`);
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
- return { version, major, minor, patch, isUE56OrAbove };
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
- return { version: 'unknown', major: 0, minor: 0, patch: 0, isUE56OrAbove: false };
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 normalizedMode = mode.charAt(0).toUpperCase() + mode.slice(1).toLowerCase();
932
-
933
- // Check if the viewmode is known to be unsafe
934
- if (this.UNSAFE_VIEWMODES.includes(normalizedMode)) {
935
- this.log.warn(`Viewmode '${normalizedMode}' is known to cause crashes. Using safe alternative.`);
936
-
937
- // For visualizeBuffer modes, we need special handling
938
- if (normalizedMode === 'BaseColor' || normalizedMode === 'WorldNormal') {
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
- // Safe mode - execute with delay
958
- return this.executeThrottledCommand(() =>
959
- this.httpCall('/remote/object/call', 'PUT', {
960
- objectPath: '/Script/Engine.Default__KismetSystemLibrary',
961
- functionName: 'ExecuteConsoleCommand',
962
- parameters: {
963
- WorldContextObject: null,
964
- Command: `viewmode ${normalizedMode}`,
965
- SpecificPlayer: null
966
- },
967
- generateTransaction: false
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
- return this.MIN_COMMAND_DELAY;
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
- const results: any[] = [];
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
  /**