unreal-engine-mcp-server 0.4.0 → 0.4.4

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