unreal-engine-mcp-server 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +57 -0
- package/.env.production +25 -0
- package/.eslintrc.json +54 -0
- package/.github/workflows/publish-mcp.yml +75 -0
- package/Dockerfile +54 -0
- package/LICENSE +21 -0
- package/Public/icon.png +0 -0
- package/README.md +209 -0
- package/claude_desktop_config_example.json +13 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +7 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +484 -0
- package/dist/prompts/index.d.ts +14 -0
- package/dist/prompts/index.js +38 -0
- package/dist/python-utils.d.ts +29 -0
- package/dist/python-utils.js +54 -0
- package/dist/resources/actors.d.ts +13 -0
- package/dist/resources/actors.js +83 -0
- package/dist/resources/assets.d.ts +23 -0
- package/dist/resources/assets.js +245 -0
- package/dist/resources/levels.d.ts +17 -0
- package/dist/resources/levels.js +94 -0
- package/dist/tools/actors.d.ts +51 -0
- package/dist/tools/actors.js +459 -0
- package/dist/tools/animation.d.ts +196 -0
- package/dist/tools/animation.js +579 -0
- package/dist/tools/assets.d.ts +21 -0
- package/dist/tools/assets.js +304 -0
- package/dist/tools/audio.d.ts +170 -0
- package/dist/tools/audio.js +416 -0
- package/dist/tools/blueprint.d.ts +144 -0
- package/dist/tools/blueprint.js +652 -0
- package/dist/tools/build_environment_advanced.d.ts +66 -0
- package/dist/tools/build_environment_advanced.js +484 -0
- package/dist/tools/consolidated-tool-definitions.d.ts +2598 -0
- package/dist/tools/consolidated-tool-definitions.js +607 -0
- package/dist/tools/consolidated-tool-handlers.d.ts +2 -0
- package/dist/tools/consolidated-tool-handlers.js +1050 -0
- package/dist/tools/debug.d.ts +185 -0
- package/dist/tools/debug.js +265 -0
- package/dist/tools/editor.d.ts +88 -0
- package/dist/tools/editor.js +365 -0
- package/dist/tools/engine.d.ts +30 -0
- package/dist/tools/engine.js +36 -0
- package/dist/tools/foliage.d.ts +155 -0
- package/dist/tools/foliage.js +525 -0
- package/dist/tools/introspection.d.ts +98 -0
- package/dist/tools/introspection.js +683 -0
- package/dist/tools/landscape.d.ts +158 -0
- package/dist/tools/landscape.js +375 -0
- package/dist/tools/level.d.ts +110 -0
- package/dist/tools/level.js +362 -0
- package/dist/tools/lighting.d.ts +159 -0
- package/dist/tools/lighting.js +1179 -0
- package/dist/tools/materials.d.ts +34 -0
- package/dist/tools/materials.js +146 -0
- package/dist/tools/niagara.d.ts +145 -0
- package/dist/tools/niagara.js +289 -0
- package/dist/tools/performance.d.ts +163 -0
- package/dist/tools/performance.js +412 -0
- package/dist/tools/physics.d.ts +189 -0
- package/dist/tools/physics.js +784 -0
- package/dist/tools/rc.d.ts +110 -0
- package/dist/tools/rc.js +363 -0
- package/dist/tools/sequence.d.ts +112 -0
- package/dist/tools/sequence.js +675 -0
- package/dist/tools/tool-definitions.d.ts +4919 -0
- package/dist/tools/tool-definitions.js +891 -0
- package/dist/tools/tool-handlers.d.ts +47 -0
- package/dist/tools/tool-handlers.js +830 -0
- package/dist/tools/ui.d.ts +171 -0
- package/dist/tools/ui.js +337 -0
- package/dist/tools/visual.d.ts +29 -0
- package/dist/tools/visual.js +67 -0
- package/dist/types/env.d.ts +10 -0
- package/dist/types/env.js +18 -0
- package/dist/types/index.d.ts +323 -0
- package/dist/types/index.js +28 -0
- package/dist/types/tool-types.d.ts +274 -0
- package/dist/types/tool-types.js +13 -0
- package/dist/unreal-bridge.d.ts +126 -0
- package/dist/unreal-bridge.js +992 -0
- package/dist/utils/cache-manager.d.ts +64 -0
- package/dist/utils/cache-manager.js +176 -0
- package/dist/utils/error-handler.d.ts +66 -0
- package/dist/utils/error-handler.js +243 -0
- package/dist/utils/errors.d.ts +133 -0
- package/dist/utils/errors.js +256 -0
- package/dist/utils/http.d.ts +26 -0
- package/dist/utils/http.js +135 -0
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.js +32 -0
- package/dist/utils/normalize.d.ts +17 -0
- package/dist/utils/normalize.js +49 -0
- package/dist/utils/response-validator.d.ts +34 -0
- package/dist/utils/response-validator.js +121 -0
- package/dist/utils/safe-json.d.ts +4 -0
- package/dist/utils/safe-json.js +97 -0
- package/dist/utils/stdio-redirect.d.ts +2 -0
- package/dist/utils/stdio-redirect.js +20 -0
- package/dist/utils/validation.d.ts +50 -0
- package/dist/utils/validation.js +173 -0
- package/mcp-config-example.json +14 -0
- package/package.json +63 -0
- package/server.json +60 -0
- package/src/cli.ts +7 -0
- package/src/index.ts +543 -0
- package/src/prompts/index.ts +51 -0
- package/src/python/editor_compat.py +181 -0
- package/src/python-utils.ts +57 -0
- package/src/resources/actors.ts +92 -0
- package/src/resources/assets.ts +251 -0
- package/src/resources/levels.ts +83 -0
- package/src/tools/actors.ts +480 -0
- package/src/tools/animation.ts +713 -0
- package/src/tools/assets.ts +305 -0
- package/src/tools/audio.ts +548 -0
- package/src/tools/blueprint.ts +736 -0
- package/src/tools/build_environment_advanced.ts +526 -0
- package/src/tools/consolidated-tool-definitions.ts +619 -0
- package/src/tools/consolidated-tool-handlers.ts +1093 -0
- package/src/tools/debug.ts +368 -0
- package/src/tools/editor.ts +360 -0
- package/src/tools/engine.ts +32 -0
- package/src/tools/foliage.ts +652 -0
- package/src/tools/introspection.ts +778 -0
- package/src/tools/landscape.ts +523 -0
- package/src/tools/level.ts +410 -0
- package/src/tools/lighting.ts +1316 -0
- package/src/tools/materials.ts +148 -0
- package/src/tools/niagara.ts +312 -0
- package/src/tools/performance.ts +549 -0
- package/src/tools/physics.ts +924 -0
- package/src/tools/rc.ts +437 -0
- package/src/tools/sequence.ts +791 -0
- package/src/tools/tool-definitions.ts +907 -0
- package/src/tools/tool-handlers.ts +941 -0
- package/src/tools/ui.ts +499 -0
- package/src/tools/visual.ts +60 -0
- package/src/types/env.ts +27 -0
- package/src/types/index.ts +414 -0
- package/src/types/tool-types.ts +343 -0
- package/src/unreal-bridge.ts +1118 -0
- package/src/utils/cache-manager.ts +213 -0
- package/src/utils/error-handler.ts +320 -0
- package/src/utils/errors.ts +312 -0
- package/src/utils/http.ts +184 -0
- package/src/utils/logger.ts +30 -0
- package/src/utils/normalize.ts +54 -0
- package/src/utils/response-validator.ts +145 -0
- package/src/utils/safe-json.ts +112 -0
- package/src/utils/stdio-redirect.ts +18 -0
- package/src/utils/validation.ts +212 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { createHttpClient } from './utils/http.js';
|
|
3
|
+
import { Logger } from './utils/logger.js';
|
|
4
|
+
import { loadEnv } from './types/env.js';
|
|
5
|
+
export class UnrealBridge {
|
|
6
|
+
ws;
|
|
7
|
+
http = createHttpClient('');
|
|
8
|
+
env = loadEnv();
|
|
9
|
+
log = new Logger('UnrealBridge');
|
|
10
|
+
connected = false;
|
|
11
|
+
reconnectTimer;
|
|
12
|
+
reconnectAttempts = 0;
|
|
13
|
+
MAX_RECONNECT_ATTEMPTS = 5;
|
|
14
|
+
BASE_RECONNECT_DELAY = 1000;
|
|
15
|
+
// Command queue for throttling
|
|
16
|
+
commandQueue = [];
|
|
17
|
+
isProcessing = false;
|
|
18
|
+
MIN_COMMAND_DELAY = 100; // Increased to prevent console spam
|
|
19
|
+
MAX_COMMAND_DELAY = 500; // Maximum delay for heavy operations
|
|
20
|
+
STAT_COMMAND_DELAY = 300; // Special delay for stat commands to avoid warnings
|
|
21
|
+
lastCommandTime = 0;
|
|
22
|
+
lastStatCommandTime = 0; // Track stat commands separately
|
|
23
|
+
// Safe viewmodes that won't cause crashes (per docs and testing)
|
|
24
|
+
_SAFE_VIEWMODES = [
|
|
25
|
+
'Lit', 'Unlit', 'Wireframe', 'DetailLighting',
|
|
26
|
+
'LightingOnly', 'ReflectionOverride', 'ShaderComplexity'
|
|
27
|
+
];
|
|
28
|
+
// Unsafe viewmodes that can cause crashes or instability via visualizeBuffer
|
|
29
|
+
UNSAFE_VIEWMODES = [
|
|
30
|
+
'BaseColor', 'WorldNormal', 'Metallic', 'Specular',
|
|
31
|
+
'Roughness', 'SubsurfaceColor', 'Opacity',
|
|
32
|
+
'LightComplexity', 'LightmapDensity',
|
|
33
|
+
'StationaryLightOverlap', 'CollisionPawn', 'CollisionVisibility'
|
|
34
|
+
];
|
|
35
|
+
// Python script templates for EditorLevelLibrary access
|
|
36
|
+
PYTHON_TEMPLATES = {
|
|
37
|
+
GET_ALL_ACTORS: {
|
|
38
|
+
name: 'get_all_actors',
|
|
39
|
+
script: `
|
|
40
|
+
import unreal
|
|
41
|
+
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
42
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
43
|
+
actors = subsys.get_all_level_actors()
|
|
44
|
+
result = [{'name': a.get_name(), 'class': a.get_class().get_name(), 'path': a.get_path_name()} for a in actors]
|
|
45
|
+
print(f"RESULT:{result}")
|
|
46
|
+
`.trim()
|
|
47
|
+
},
|
|
48
|
+
SPAWN_ACTOR_AT_LOCATION: {
|
|
49
|
+
name: 'spawn_actor',
|
|
50
|
+
script: `
|
|
51
|
+
import unreal
|
|
52
|
+
location = unreal.Vector({x}, {y}, {z})
|
|
53
|
+
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
54
|
+
actor_class = unreal.EditorAssetLibrary.load_asset("{class_path}")
|
|
55
|
+
if actor_class:
|
|
56
|
+
# Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
|
|
57
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
58
|
+
spawned = subsys.spawn_actor_from_object(actor_class, location, rotation)
|
|
59
|
+
print(f"RESULT:{{'success': True, 'actor': spawned.get_name()}}")
|
|
60
|
+
else:
|
|
61
|
+
print(f"RESULT:{{'success': False, 'error': 'Failed to load actor class'}}")
|
|
62
|
+
`.trim()
|
|
63
|
+
},
|
|
64
|
+
DELETE_ACTOR: {
|
|
65
|
+
name: 'delete_actor',
|
|
66
|
+
script: `
|
|
67
|
+
import unreal
|
|
68
|
+
import json
|
|
69
|
+
subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
70
|
+
actors = subsys.get_all_level_actors()
|
|
71
|
+
found = False
|
|
72
|
+
for actor in actors:
|
|
73
|
+
if not actor:
|
|
74
|
+
continue
|
|
75
|
+
label = actor.get_actor_label()
|
|
76
|
+
name = actor.get_name()
|
|
77
|
+
if label == "{actor_name}" or name == "{actor_name}" or label.lower().startswith("{actor_name}".lower()+"_"):
|
|
78
|
+
subsys.destroy_actor(actor)
|
|
79
|
+
print("RESULT:" + json.dumps({'success': True, 'deleted': label}))
|
|
80
|
+
found = True
|
|
81
|
+
break
|
|
82
|
+
if not found:
|
|
83
|
+
print("RESULT:" + json.dumps({'success': False, 'error': 'Actor not found'}))
|
|
84
|
+
`.trim()
|
|
85
|
+
},
|
|
86
|
+
CREATE_ASSET: {
|
|
87
|
+
name: 'create_asset',
|
|
88
|
+
script: `
|
|
89
|
+
import unreal
|
|
90
|
+
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
|
91
|
+
factory = unreal.{factory_class}()
|
|
92
|
+
asset = asset_tools.create_asset("{asset_name}", "{package_path}", {asset_class}, factory)
|
|
93
|
+
if asset:
|
|
94
|
+
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
|
95
|
+
print(f"RESULT:{{'success': True, 'path': asset.get_path_name()}}")
|
|
96
|
+
else:
|
|
97
|
+
print(f"RESULT:{{'success': False, 'error': 'Failed to create asset'}}")
|
|
98
|
+
`.trim()
|
|
99
|
+
},
|
|
100
|
+
SET_VIEWPORT_CAMERA: {
|
|
101
|
+
name: 'set_viewport_camera',
|
|
102
|
+
script: `
|
|
103
|
+
import unreal
|
|
104
|
+
location = unreal.Vector({x}, {y}, {z})
|
|
105
|
+
rotation = unreal.Rotator({pitch}, {yaw}, {roll})
|
|
106
|
+
# Use UnrealEditorSubsystem for viewport operations (UE5.1+)
|
|
107
|
+
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
108
|
+
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
109
|
+
if ues:
|
|
110
|
+
ues.set_level_viewport_camera_info(location, rotation)
|
|
111
|
+
try:
|
|
112
|
+
if les:
|
|
113
|
+
les.editor_invalidate_viewports()
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
print(f"RESULT:{{'success': True, 'location': [{x}, {y}, {z}], 'rotation': [{pitch}, {yaw}, {roll}]}}")
|
|
117
|
+
else:
|
|
118
|
+
print(f"RESULT:{{'success': False, 'error': 'UnrealEditorSubsystem not available'}}")
|
|
119
|
+
`.trim()
|
|
120
|
+
},
|
|
121
|
+
BUILD_LIGHTING: {
|
|
122
|
+
name: 'build_lighting',
|
|
123
|
+
script: `
|
|
124
|
+
import unreal
|
|
125
|
+
try:
|
|
126
|
+
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
127
|
+
if les:
|
|
128
|
+
q = unreal.LightingBuildQuality.{quality}
|
|
129
|
+
les.build_light_maps(q, True)
|
|
130
|
+
print(f"RESULT:{{'success': True, 'quality': '{quality}', 'method': 'LevelEditorSubsystem'}}")
|
|
131
|
+
else:
|
|
132
|
+
print(f"RESULT:{{'success': False, 'error': 'LevelEditorSubsystem not available'}}")
|
|
133
|
+
except Exception as e:
|
|
134
|
+
print(f"RESULT:{{'success': False, 'error': str(e)}}")
|
|
135
|
+
`.trim()
|
|
136
|
+
},
|
|
137
|
+
SAVE_ALL_DIRTY_PACKAGES: {
|
|
138
|
+
name: 'save_dirty_packages',
|
|
139
|
+
script: `
|
|
140
|
+
import unreal
|
|
141
|
+
saved = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
|
142
|
+
print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
|
|
143
|
+
`.trim()
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
get isConnected() { return this.connected; }
|
|
147
|
+
/**
|
|
148
|
+
* Attempt to connect with retries
|
|
149
|
+
* @param maxAttempts Maximum number of connection attempts
|
|
150
|
+
* @param timeoutMs Timeout for each connection attempt in milliseconds
|
|
151
|
+
* @param retryDelayMs Delay between retry attempts in milliseconds
|
|
152
|
+
* @returns Promise that resolves when connected or rejects after all attempts fail
|
|
153
|
+
*/
|
|
154
|
+
async tryConnect(maxAttempts = 3, timeoutMs = 5000, retryDelayMs = 2000) {
|
|
155
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
156
|
+
try {
|
|
157
|
+
this.log.info(`Connection attempt ${attempt}/${maxAttempts}`);
|
|
158
|
+
await this.connect(timeoutMs);
|
|
159
|
+
return true; // Successfully connected
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
this.log.warn(`Connection attempt ${attempt} failed:`, err);
|
|
163
|
+
if (attempt < maxAttempts) {
|
|
164
|
+
this.log.info(`Retrying in ${retryDelayMs}ms...`);
|
|
165
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
this.log.error(`All ${maxAttempts} connection attempts failed`);
|
|
169
|
+
return false; // All attempts failed
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
async connect(timeoutMs = 5000) {
|
|
176
|
+
const wsUrl = `ws://${this.env.UE_HOST}:${this.env.UE_RC_WS_PORT}`;
|
|
177
|
+
const httpBase = `http://${this.env.UE_HOST}:${this.env.UE_RC_HTTP_PORT}`;
|
|
178
|
+
this.http = createHttpClient(httpBase);
|
|
179
|
+
this.log.info(`Connecting to UE Remote Control: ${wsUrl}`);
|
|
180
|
+
this.ws = new WebSocket(wsUrl);
|
|
181
|
+
await new Promise((resolve, reject) => {
|
|
182
|
+
if (!this.ws)
|
|
183
|
+
return reject(new Error('WS not created'));
|
|
184
|
+
// Setup timeout
|
|
185
|
+
const timeout = setTimeout(() => {
|
|
186
|
+
this.log.warn(`Connection timeout after ${timeoutMs}ms`);
|
|
187
|
+
if (this.ws) {
|
|
188
|
+
this.ws.removeAllListeners();
|
|
189
|
+
// Only close if the websocket is in CONNECTING state
|
|
190
|
+
if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
191
|
+
try {
|
|
192
|
+
this.ws.terminate(); // Use terminate instead of close for immediate cleanup
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
// Ignore close errors
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.ws = undefined;
|
|
199
|
+
}
|
|
200
|
+
reject(new Error('Connection timeout: Unreal Engine may not be running or Remote Control is not enabled'));
|
|
201
|
+
}, timeoutMs);
|
|
202
|
+
// Success handler
|
|
203
|
+
const onOpen = () => {
|
|
204
|
+
clearTimeout(timeout);
|
|
205
|
+
this.connected = true;
|
|
206
|
+
this.log.info('Connected to Unreal Remote Control');
|
|
207
|
+
this.startCommandProcessor(); // Start command processor on connect
|
|
208
|
+
resolve();
|
|
209
|
+
};
|
|
210
|
+
// Error handler
|
|
211
|
+
const onError = (err) => {
|
|
212
|
+
clearTimeout(timeout);
|
|
213
|
+
this.log.error('WebSocket error', err);
|
|
214
|
+
if (this.ws) {
|
|
215
|
+
this.ws.removeAllListeners();
|
|
216
|
+
try {
|
|
217
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
218
|
+
this.ws.terminate();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
// Ignore close errors
|
|
223
|
+
}
|
|
224
|
+
this.ws = undefined;
|
|
225
|
+
}
|
|
226
|
+
reject(new Error(`Failed to connect: ${err.message}`));
|
|
227
|
+
};
|
|
228
|
+
// Close handler (if closed before open)
|
|
229
|
+
const onClose = () => {
|
|
230
|
+
if (!this.connected) {
|
|
231
|
+
clearTimeout(timeout);
|
|
232
|
+
reject(new Error('Connection closed before establishing'));
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// Normal close after connection was established
|
|
236
|
+
this.connected = false;
|
|
237
|
+
this.log.warn('WebSocket closed');
|
|
238
|
+
this.scheduleReconnect();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
// Message handler (currently best-effort logging)
|
|
242
|
+
const onMessage = (raw) => {
|
|
243
|
+
try {
|
|
244
|
+
const msg = JSON.parse(String(raw));
|
|
245
|
+
this.log.debug('WS message', msg);
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
this.log.error('Failed parsing WS message', e);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
// Attach listeners
|
|
252
|
+
this.ws.once('open', onOpen);
|
|
253
|
+
this.ws.once('error', onError);
|
|
254
|
+
this.ws.on('close', onClose);
|
|
255
|
+
this.ws.on('message', onMessage);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async httpCall(path, method = 'POST', body) {
|
|
259
|
+
const url = path.startsWith('/') ? path : `/${path}`;
|
|
260
|
+
const started = Date.now();
|
|
261
|
+
// Fix Content-Length header issue - ensure body is properly handled
|
|
262
|
+
if (body === undefined || body === null) {
|
|
263
|
+
body = method === 'GET' ? undefined : {};
|
|
264
|
+
}
|
|
265
|
+
// Add timeout wrapper to prevent hanging
|
|
266
|
+
const CALL_TIMEOUT = 10000; // 10 seconds timeout
|
|
267
|
+
// CRITICAL: Intercept and block dangerous console commands at HTTP level
|
|
268
|
+
if (url === '/remote/object/call' && body?.functionName === 'ExecuteConsoleCommand') {
|
|
269
|
+
const command = body?.parameters?.Command;
|
|
270
|
+
if (command && typeof command === 'string') {
|
|
271
|
+
const cmdLower = command.trim().toLowerCase();
|
|
272
|
+
// List of commands that cause crashes
|
|
273
|
+
const crashCommands = [
|
|
274
|
+
'buildpaths', // Causes access violation 0x0000000000000060
|
|
275
|
+
'rebuildnavigation', // Can crash without nav system
|
|
276
|
+
'buildhierarchicallod', // Can crash without proper setup
|
|
277
|
+
'buildlandscapeinfo', // Can crash without landscape
|
|
278
|
+
'rebuildselectednavigation' // Nav-related crash
|
|
279
|
+
];
|
|
280
|
+
// Check if this is a crash-inducing command
|
|
281
|
+
if (crashCommands.some(dangerous => cmdLower === dangerous || cmdLower.startsWith(dangerous + ' '))) {
|
|
282
|
+
this.log.warn(`BLOCKED dangerous command that causes crashes: ${command}`);
|
|
283
|
+
// Return a safe error response instead of executing
|
|
284
|
+
return {
|
|
285
|
+
success: false,
|
|
286
|
+
error: `Command '${command}' blocked: This command can cause Unreal Engine to crash. Use the Python API alternatives instead.`
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// Also block other dangerous commands
|
|
290
|
+
const dangerousPatterns = [
|
|
291
|
+
'quit', 'exit', 'r.gpucrash', 'debug crash',
|
|
292
|
+
'viewmode visualizebuffer' // These can crash in certain states
|
|
293
|
+
];
|
|
294
|
+
if (dangerousPatterns.some(pattern => cmdLower.includes(pattern))) {
|
|
295
|
+
this.log.warn(`BLOCKED potentially dangerous command: ${command}`);
|
|
296
|
+
return {
|
|
297
|
+
success: false,
|
|
298
|
+
error: `Command '${command}' blocked for safety.`
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Retry logic with exponential backoff and timeout
|
|
304
|
+
let lastError;
|
|
305
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
306
|
+
try {
|
|
307
|
+
// For GET requests, send payload as query parameters (not in body)
|
|
308
|
+
const config = { url, method, timeout: CALL_TIMEOUT };
|
|
309
|
+
if (method === 'GET' && body && typeof body === 'object') {
|
|
310
|
+
config.params = body;
|
|
311
|
+
}
|
|
312
|
+
else if (body !== undefined) {
|
|
313
|
+
config.data = body;
|
|
314
|
+
}
|
|
315
|
+
// Wrap with timeout promise to ensure we don't hang
|
|
316
|
+
const requestPromise = this.http.request(config);
|
|
317
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
reject(new Error(`Request timeout after ${CALL_TIMEOUT}ms`));
|
|
320
|
+
}, CALL_TIMEOUT);
|
|
321
|
+
});
|
|
322
|
+
const resp = await Promise.race([requestPromise, timeoutPromise]);
|
|
323
|
+
const ms = Date.now() - started;
|
|
324
|
+
this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms`);
|
|
325
|
+
return resp.data;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
lastError = error;
|
|
329
|
+
const delay = Math.min(1000 * Math.pow(2, attempt), 5000); // Exponential backoff with 5s max
|
|
330
|
+
// Log timeout errors specifically
|
|
331
|
+
if (error.message?.includes('timeout')) {
|
|
332
|
+
this.log.warn(`HTTP request timed out (attempt ${attempt + 1}/3): ${url}`);
|
|
333
|
+
}
|
|
334
|
+
if (attempt < 2) {
|
|
335
|
+
this.log.warn(`HTTP request failed (attempt ${attempt + 1}/3), retrying in ${delay}ms...`);
|
|
336
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
337
|
+
// If connection error, try to reconnect
|
|
338
|
+
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
|
|
339
|
+
this.scheduleReconnect();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
throw lastError;
|
|
345
|
+
}
|
|
346
|
+
// Generic function call via Remote Control HTTP API
|
|
347
|
+
async call(body) {
|
|
348
|
+
// Using HTTP endpoint /remote/object/call
|
|
349
|
+
const result = await this.httpCall('/remote/object/call', 'PUT', {
|
|
350
|
+
generateTransaction: false,
|
|
351
|
+
...body
|
|
352
|
+
});
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
async getExposed() {
|
|
356
|
+
return this.httpCall('/remote/preset', 'GET');
|
|
357
|
+
}
|
|
358
|
+
// Execute a console command safely with validation and throttling
|
|
359
|
+
async executeConsoleCommand(command) {
|
|
360
|
+
// Validate command is not empty
|
|
361
|
+
if (!command || typeof command !== 'string') {
|
|
362
|
+
throw new Error('Invalid command: must be a non-empty string');
|
|
363
|
+
}
|
|
364
|
+
const cmdTrimmed = command.trim();
|
|
365
|
+
if (cmdTrimmed.length === 0) {
|
|
366
|
+
// Return success for empty commands to match UE behavior
|
|
367
|
+
return { success: true, message: 'Empty command ignored' };
|
|
368
|
+
}
|
|
369
|
+
// Check for dangerous commands
|
|
370
|
+
const dangerousCommands = [
|
|
371
|
+
'quit', 'exit', 'delete', 'destroy', 'kill', 'crash',
|
|
372
|
+
'viewmode visualizebuffer basecolor',
|
|
373
|
+
'viewmode visualizebuffer worldnormal',
|
|
374
|
+
'r.gpucrash',
|
|
375
|
+
'buildpaths', // Can cause access violation if nav system not initialized
|
|
376
|
+
'rebuildnavigation' // Can also crash without proper nav setup
|
|
377
|
+
];
|
|
378
|
+
const cmdLower = cmdTrimmed.toLowerCase();
|
|
379
|
+
if (dangerousCommands.some(dangerous => cmdLower.includes(dangerous))) {
|
|
380
|
+
throw new Error(`Dangerous command blocked: ${command}`);
|
|
381
|
+
}
|
|
382
|
+
// Determine priority based on command type
|
|
383
|
+
let priority = 7; // Default priority
|
|
384
|
+
if (command.includes('BuildLighting') || command.includes('BuildPaths')) {
|
|
385
|
+
priority = 1; // Heavy operation
|
|
386
|
+
}
|
|
387
|
+
else if (command.includes('summon') || command.includes('spawn')) {
|
|
388
|
+
priority = 5; // Medium operation
|
|
389
|
+
}
|
|
390
|
+
else if (command.startsWith('stat') || command.startsWith('show')) {
|
|
391
|
+
priority = 9; // Light operation
|
|
392
|
+
}
|
|
393
|
+
// Known invalid command patterns
|
|
394
|
+
const invalidPatterns = [
|
|
395
|
+
/^\d+$/, // Just numbers
|
|
396
|
+
/^invalid_command/i,
|
|
397
|
+
/^this_is_not_a_valid/i,
|
|
398
|
+
];
|
|
399
|
+
const isLikelyInvalid = invalidPatterns.some(pattern => pattern.test(cmdTrimmed));
|
|
400
|
+
if (isLikelyInvalid) {
|
|
401
|
+
this.log.warn(`Command appears invalid: ${cmdTrimmed}`);
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const result = await this.executeThrottledCommand(() => this.httpCall('/remote/object/call', 'PUT', {
|
|
405
|
+
objectPath: '/Script/Engine.Default__KismetSystemLibrary',
|
|
406
|
+
functionName: 'ExecuteConsoleCommand',
|
|
407
|
+
parameters: {
|
|
408
|
+
WorldContextObject: null,
|
|
409
|
+
Command: cmdTrimmed,
|
|
410
|
+
SpecificPlayer: null
|
|
411
|
+
},
|
|
412
|
+
generateTransaction: false
|
|
413
|
+
}), priority);
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
this.log.error(`Console command failed: ${cmdTrimmed}`, error);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Try to execute a Python command via the PythonScriptPlugin, fallback to `py` console command.
|
|
422
|
+
async executePython(command) {
|
|
423
|
+
const isMultiLine = /[\r\n]/.test(command) || command.includes(';');
|
|
424
|
+
try {
|
|
425
|
+
// Use ExecutePythonCommandEx with appropriate mode based on content
|
|
426
|
+
return await this.httpCall('/remote/object/call', 'PUT', {
|
|
427
|
+
objectPath: '/Script/PythonScriptPlugin.Default__PythonScriptLibrary',
|
|
428
|
+
functionName: 'ExecutePythonCommandEx',
|
|
429
|
+
parameters: {
|
|
430
|
+
PythonCommand: command,
|
|
431
|
+
ExecutionMode: isMultiLine ? 'ExecuteFile' : 'ExecuteStatement',
|
|
432
|
+
FileExecutionScope: 'Private'
|
|
433
|
+
},
|
|
434
|
+
generateTransaction: false
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
try {
|
|
439
|
+
// Fallback to ExecutePythonCommand (more tolerant for multi-line)
|
|
440
|
+
return await this.httpCall('/remote/object/call', 'PUT', {
|
|
441
|
+
objectPath: '/Script/PythonScriptPlugin.Default__PythonScriptLibrary',
|
|
442
|
+
functionName: 'ExecutePythonCommand',
|
|
443
|
+
parameters: {
|
|
444
|
+
Command: command
|
|
445
|
+
},
|
|
446
|
+
generateTransaction: false
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// Final fallback: execute via console py command
|
|
451
|
+
this.log.warn('PythonScriptLibrary not available or failed, falling back to console `py` command');
|
|
452
|
+
// For simple single-line commands
|
|
453
|
+
if (!isMultiLine) {
|
|
454
|
+
return await this.executeConsoleCommand(`py ${command}`);
|
|
455
|
+
}
|
|
456
|
+
// For multi-line scripts, try to execute as a block
|
|
457
|
+
try {
|
|
458
|
+
// Try executing as a single exec block
|
|
459
|
+
// Properly escape the script for Python exec
|
|
460
|
+
const escapedScript = command
|
|
461
|
+
.replace(/\\/g, '\\\\')
|
|
462
|
+
.replace(/"/g, '\\"')
|
|
463
|
+
.replace(/\n/g, '\\n')
|
|
464
|
+
.replace(/\r/g, '');
|
|
465
|
+
return await this.executeConsoleCommand(`py exec("${escapedScript}")`);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// If that fails, break into smaller chunks
|
|
469
|
+
try {
|
|
470
|
+
// First ensure unreal is imported
|
|
471
|
+
await this.executeConsoleCommand('py import unreal');
|
|
472
|
+
// For complex multi-line scripts, execute in logical chunks
|
|
473
|
+
const commandWithoutImport = command.replace(/^\s*import\s+unreal\s*;?\s*/m, '');
|
|
474
|
+
// Split by semicolons first, then by newlines
|
|
475
|
+
const statements = commandWithoutImport
|
|
476
|
+
.split(/[;\n]/)
|
|
477
|
+
.map(s => s.trim())
|
|
478
|
+
.filter(s => s.length > 0 && !s.startsWith('#'));
|
|
479
|
+
let result = null;
|
|
480
|
+
for (const stmt of statements) {
|
|
481
|
+
// Skip if statement is too long for console
|
|
482
|
+
if (stmt.length > 200) {
|
|
483
|
+
// Try to execute as a single exec block
|
|
484
|
+
const miniScript = `exec("""${stmt.replace(/"/g, '\\"')}""")`;
|
|
485
|
+
result = await this.executeConsoleCommand(`py ${miniScript}`);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
result = await this.executeConsoleCommand(`py ${stmt}`);
|
|
489
|
+
}
|
|
490
|
+
// Small delay between commands
|
|
491
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
492
|
+
}
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// Final fallback: execute line by line
|
|
497
|
+
const lines = command.split('\n').filter(line => line.trim().length > 0);
|
|
498
|
+
let result = null;
|
|
499
|
+
for (const line of lines) {
|
|
500
|
+
// Skip comments
|
|
501
|
+
if (line.trim().startsWith('#')) {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
result = await this.executeConsoleCommand(`py ${line.trim()}`);
|
|
505
|
+
// Small delay between commands to ensure execution order
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Connection recovery
|
|
515
|
+
scheduleReconnect() {
|
|
516
|
+
if (this.reconnectTimer || this.connected) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
|
520
|
+
this.log.error('Max reconnection attempts reached. Please check Unreal Engine.');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// Exponential backoff with jitter
|
|
524
|
+
const delay = Math.min(this.BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000, 30000 // Max 30 seconds
|
|
525
|
+
);
|
|
526
|
+
this.log.info(`Scheduling reconnection attempt ${this.reconnectAttempts + 1}/${this.MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
|
|
527
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
528
|
+
this.reconnectTimer = undefined;
|
|
529
|
+
this.reconnectAttempts++;
|
|
530
|
+
try {
|
|
531
|
+
await this.connect();
|
|
532
|
+
this.reconnectAttempts = 0;
|
|
533
|
+
this.log.info('Successfully reconnected to Unreal Engine');
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
this.log.error('Reconnection attempt failed:', err);
|
|
537
|
+
this.scheduleReconnect();
|
|
538
|
+
}
|
|
539
|
+
}, delay);
|
|
540
|
+
}
|
|
541
|
+
// Graceful shutdown
|
|
542
|
+
async disconnect() {
|
|
543
|
+
if (this.reconnectTimer) {
|
|
544
|
+
clearTimeout(this.reconnectTimer);
|
|
545
|
+
this.reconnectTimer = undefined;
|
|
546
|
+
}
|
|
547
|
+
if (this.ws) {
|
|
548
|
+
this.ws.close();
|
|
549
|
+
this.ws = undefined;
|
|
550
|
+
}
|
|
551
|
+
this.connected = false;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Enhanced Editor Function Access
|
|
555
|
+
* Use Python scripting as a bridge to access modern Editor Subsystem functions
|
|
556
|
+
*/
|
|
557
|
+
async executeEditorFunction(functionName, params) {
|
|
558
|
+
const template = this.PYTHON_TEMPLATES[functionName];
|
|
559
|
+
if (!template) {
|
|
560
|
+
throw new Error(`Unknown editor function: ${functionName}`);
|
|
561
|
+
}
|
|
562
|
+
let script = template.script;
|
|
563
|
+
// Replace parameters in the script
|
|
564
|
+
if (params) {
|
|
565
|
+
for (const [key, value] of Object.entries(params)) {
|
|
566
|
+
const placeholder = `{${key}}`;
|
|
567
|
+
script = script.replace(new RegExp(placeholder, 'g'), String(value));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
// Execute Python script with result parsing
|
|
572
|
+
const result = await this.executePythonWithResult(script);
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
this.log.error(`Failed to execute editor function ${functionName}:`, error);
|
|
577
|
+
// Fallback to console command if Python fails
|
|
578
|
+
return this.executeFallbackCommand(functionName, params);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Execute Python script and parse the result
|
|
583
|
+
*/
|
|
584
|
+
async executePythonWithResult(script) {
|
|
585
|
+
try {
|
|
586
|
+
// Wrap script to capture output so we can parse RESULT: lines reliably
|
|
587
|
+
const wrappedScript = `
|
|
588
|
+
import sys
|
|
589
|
+
import io
|
|
590
|
+
old_stdout = sys.stdout
|
|
591
|
+
sys.stdout = buffer = io.StringIO()
|
|
592
|
+
try:
|
|
593
|
+
${script.split('\n').join('\n ')}
|
|
594
|
+
finally:
|
|
595
|
+
output = buffer.getvalue()
|
|
596
|
+
sys.stdout = old_stdout
|
|
597
|
+
if output:
|
|
598
|
+
print(output)
|
|
599
|
+
`.trim()
|
|
600
|
+
.replace(/\r?\n/g, '\n');
|
|
601
|
+
const response = await this.executePython(wrappedScript);
|
|
602
|
+
// Extract textual output from various response shapes
|
|
603
|
+
let out = '';
|
|
604
|
+
try {
|
|
605
|
+
if (response && typeof response === 'string') {
|
|
606
|
+
out = response;
|
|
607
|
+
}
|
|
608
|
+
else if (response && typeof response === 'object') {
|
|
609
|
+
// Common RC Python response contains LogOutput array entries with .Output strings
|
|
610
|
+
if (Array.isArray(response.LogOutput)) {
|
|
611
|
+
out = response.LogOutput.map((l) => l.Output || '').join('');
|
|
612
|
+
}
|
|
613
|
+
else if (typeof response.Output === 'string') {
|
|
614
|
+
out = response.Output;
|
|
615
|
+
}
|
|
616
|
+
else if (typeof response.result === 'string') {
|
|
617
|
+
out = response.result;
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
// Fallback to stringifying object (may still include RESULT in nested fields)
|
|
621
|
+
out = JSON.stringify(response);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
out = String(response || '');
|
|
627
|
+
}
|
|
628
|
+
// Find the last RESULT: JSON block in the output for robustness
|
|
629
|
+
const matches = Array.from(out.matchAll(/RESULT:({[\s\S]*?})/g));
|
|
630
|
+
if (matches.length > 0) {
|
|
631
|
+
const last = matches[matches.length - 1][1];
|
|
632
|
+
try {
|
|
633
|
+
// Accept single quotes and True/False from Python repr if present
|
|
634
|
+
const normalized = last
|
|
635
|
+
.replace(/'/g, '"')
|
|
636
|
+
.replace(/\bTrue\b/g, 'true')
|
|
637
|
+
.replace(/\bFalse\b/g, 'false');
|
|
638
|
+
return JSON.parse(normalized);
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return { raw: last };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// If no RESULT: marker, return the best-effort textual output or original response
|
|
645
|
+
return typeof response !== 'undefined' ? response : out;
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
this.log.warn('Python execution failed, trying direct execution');
|
|
649
|
+
return this.executePython(script);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Get the Unreal Engine version via Python and parse major/minor/patch.
|
|
654
|
+
*/
|
|
655
|
+
async getEngineVersion() {
|
|
656
|
+
try {
|
|
657
|
+
const script = `
|
|
658
|
+
import unreal, json, re
|
|
659
|
+
ver = str(unreal.SystemLibrary.get_engine_version())
|
|
660
|
+
m = re.match(r'^(\\d+)\\.(\\d+)\\.(\\d+)', ver)
|
|
661
|
+
major = int(m.group(1)) if m else 0
|
|
662
|
+
minor = int(m.group(2)) if m else 0
|
|
663
|
+
patch = int(m.group(3)) if m else 0
|
|
664
|
+
print('RESULT:' + json.dumps({'version': ver, 'major': major, 'minor': minor, 'patch': patch}))
|
|
665
|
+
`.trim();
|
|
666
|
+
const result = await this.executePythonWithResult(script);
|
|
667
|
+
const version = String(result?.version ?? 'unknown');
|
|
668
|
+
const major = Number(result?.major ?? 0) || 0;
|
|
669
|
+
const minor = Number(result?.minor ?? 0) || 0;
|
|
670
|
+
const patch = Number(result?.patch ?? 0) || 0;
|
|
671
|
+
const isUE56OrAbove = major > 5 || (major === 5 && minor >= 6);
|
|
672
|
+
return { version, major, minor, patch, isUE56OrAbove };
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
this.log.warn('Failed to get engine version via Python', error);
|
|
676
|
+
return { version: 'unknown', major: 0, minor: 0, patch: 0, isUE56OrAbove: false };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Query feature flags (Python availability, editor subsystems) via Python.
|
|
681
|
+
*/
|
|
682
|
+
async getFeatureFlags() {
|
|
683
|
+
try {
|
|
684
|
+
const script = `
|
|
685
|
+
import unreal, json
|
|
686
|
+
flags = {}
|
|
687
|
+
# Python plugin availability (class exists)
|
|
688
|
+
try:
|
|
689
|
+
_ = unreal.PythonScriptLibrary
|
|
690
|
+
flags['pythonEnabled'] = True
|
|
691
|
+
except Exception:
|
|
692
|
+
flags['pythonEnabled'] = False
|
|
693
|
+
# Editor subsystems
|
|
694
|
+
try:
|
|
695
|
+
flags['unrealEditor'] = bool(unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem))
|
|
696
|
+
except Exception:
|
|
697
|
+
flags['unrealEditor'] = False
|
|
698
|
+
try:
|
|
699
|
+
flags['levelEditor'] = bool(unreal.get_editor_subsystem(unreal.LevelEditorSubsystem))
|
|
700
|
+
except Exception:
|
|
701
|
+
flags['levelEditor'] = False
|
|
702
|
+
try:
|
|
703
|
+
flags['editorActor'] = bool(unreal.get_editor_subsystem(unreal.EditorActorSubsystem))
|
|
704
|
+
except Exception:
|
|
705
|
+
flags['editorActor'] = False
|
|
706
|
+
print('RESULT:' + json.dumps(flags))
|
|
707
|
+
`.trim();
|
|
708
|
+
const res = await this.executePythonWithResult(script);
|
|
709
|
+
return {
|
|
710
|
+
pythonEnabled: Boolean(res?.pythonEnabled),
|
|
711
|
+
subsystems: {
|
|
712
|
+
unrealEditor: Boolean(res?.unrealEditor),
|
|
713
|
+
levelEditor: Boolean(res?.levelEditor),
|
|
714
|
+
editorActor: Boolean(res?.editorActor)
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
this.log.warn('Failed to get feature flags via Python', e);
|
|
720
|
+
return { pythonEnabled: false, subsystems: { unrealEditor: false, levelEditor: false, editorActor: false } };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Fallback commands when Python is not available
|
|
725
|
+
*/
|
|
726
|
+
async executeFallbackCommand(functionName, params) {
|
|
727
|
+
switch (functionName) {
|
|
728
|
+
case 'SPAWN_ACTOR_AT_LOCATION':
|
|
729
|
+
return this.executeConsoleCommand(`summon ${params?.class_path || 'StaticMeshActor'} ${params?.x || 0} ${params?.y || 0} ${params?.z || 0}`);
|
|
730
|
+
case 'DELETE_ACTOR':
|
|
731
|
+
// Use Python-based deletion to avoid unsafe console command and improve reliability
|
|
732
|
+
return this.executePythonWithResult(this.PYTHON_TEMPLATES.DELETE_ACTOR.script.replace('{actor_name}', String(params?.actor_name || '')));
|
|
733
|
+
case 'BUILD_LIGHTING':
|
|
734
|
+
return this.executeConsoleCommand('BuildLighting');
|
|
735
|
+
default:
|
|
736
|
+
throw new Error(`No fallback available for ${functionName}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* SOLUTION 2: Safe ViewMode Switching
|
|
741
|
+
* Prevent crashes by validating and safely switching viewmodes
|
|
742
|
+
*/
|
|
743
|
+
async setSafeViewMode(mode) {
|
|
744
|
+
const normalizedMode = mode.charAt(0).toUpperCase() + mode.slice(1).toLowerCase();
|
|
745
|
+
// Check if the viewmode is known to be unsafe
|
|
746
|
+
if (this.UNSAFE_VIEWMODES.includes(normalizedMode)) {
|
|
747
|
+
this.log.warn(`Viewmode '${normalizedMode}' is known to cause crashes. Using safe alternative.`);
|
|
748
|
+
// For visualizeBuffer modes, we need special handling
|
|
749
|
+
if (normalizedMode === 'BaseColor' || normalizedMode === 'WorldNormal') {
|
|
750
|
+
// First ensure we're in a safe state
|
|
751
|
+
await this.executeConsoleCommand('viewmode Lit');
|
|
752
|
+
await this.delay(100);
|
|
753
|
+
// Try to use a safer alternative or skip
|
|
754
|
+
this.log.info(`Skipping unsafe visualizeBuffer mode: ${normalizedMode}`);
|
|
755
|
+
return {
|
|
756
|
+
success: false,
|
|
757
|
+
message: `Viewmode ${normalizedMode} skipped for safety`,
|
|
758
|
+
alternative: 'Lit'
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
// For other unsafe modes, switch to safe alternatives
|
|
762
|
+
const safeAlternative = this.getSafeAlternative(normalizedMode);
|
|
763
|
+
return this.executeConsoleCommand(`viewmode ${safeAlternative}`);
|
|
764
|
+
}
|
|
765
|
+
// Safe mode - execute with delay
|
|
766
|
+
return this.executeThrottledCommand(() => this.httpCall('/remote/object/call', 'PUT', {
|
|
767
|
+
objectPath: '/Script/Engine.Default__KismetSystemLibrary',
|
|
768
|
+
functionName: 'ExecuteConsoleCommand',
|
|
769
|
+
parameters: {
|
|
770
|
+
WorldContextObject: null,
|
|
771
|
+
Command: `viewmode ${normalizedMode}`,
|
|
772
|
+
SpecificPlayer: null
|
|
773
|
+
},
|
|
774
|
+
generateTransaction: false
|
|
775
|
+
}));
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Get safe alternative for unsafe viewmodes
|
|
779
|
+
*/
|
|
780
|
+
getSafeAlternative(unsafeMode) {
|
|
781
|
+
const alternatives = {
|
|
782
|
+
'BaseColor': 'Unlit',
|
|
783
|
+
'WorldNormal': 'Lit',
|
|
784
|
+
'Metallic': 'Lit',
|
|
785
|
+
'Specular': 'Lit',
|
|
786
|
+
'Roughness': 'Lit',
|
|
787
|
+
'LightComplexity': 'LightingOnly',
|
|
788
|
+
'ShaderComplexity': 'Wireframe',
|
|
789
|
+
'CollisionPawn': 'Wireframe',
|
|
790
|
+
'CollisionVisibility': 'Wireframe'
|
|
791
|
+
};
|
|
792
|
+
return alternatives[unsafeMode] || 'Lit';
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* SOLUTION 3: Command Throttling and Queueing
|
|
796
|
+
* Prevent rapid command execution that can overwhelm the engine
|
|
797
|
+
*/
|
|
798
|
+
async executeThrottledCommand(command, priority = 5) {
|
|
799
|
+
return new Promise((resolve, reject) => {
|
|
800
|
+
this.commandQueue.push({
|
|
801
|
+
command,
|
|
802
|
+
resolve,
|
|
803
|
+
reject,
|
|
804
|
+
priority
|
|
805
|
+
});
|
|
806
|
+
// Sort by priority (lower number = higher priority)
|
|
807
|
+
this.commandQueue.sort((a, b) => a.priority - b.priority);
|
|
808
|
+
// Process queue if not already processing
|
|
809
|
+
if (!this.isProcessing) {
|
|
810
|
+
this.processCommandQueue();
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Process command queue with appropriate delays
|
|
816
|
+
*/
|
|
817
|
+
async processCommandQueue() {
|
|
818
|
+
if (this.isProcessing || this.commandQueue.length === 0) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
this.isProcessing = true;
|
|
822
|
+
while (this.commandQueue.length > 0) {
|
|
823
|
+
const item = this.commandQueue.shift();
|
|
824
|
+
if (!item)
|
|
825
|
+
continue; // Skip if undefined
|
|
826
|
+
// Calculate delay based on time since last command
|
|
827
|
+
const timeSinceLastCommand = Date.now() - this.lastCommandTime;
|
|
828
|
+
const requiredDelay = this.calculateDelay(item.priority);
|
|
829
|
+
if (timeSinceLastCommand < requiredDelay) {
|
|
830
|
+
await this.delay(requiredDelay - timeSinceLastCommand);
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const result = await item.command();
|
|
834
|
+
item.resolve(result);
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
// Retry logic for transient failures
|
|
838
|
+
if (item.retryCount === undefined) {
|
|
839
|
+
item.retryCount = 0;
|
|
840
|
+
}
|
|
841
|
+
if (item.retryCount < 3) {
|
|
842
|
+
item.retryCount++;
|
|
843
|
+
this.log.warn(`Command failed, retrying (${item.retryCount}/3)`);
|
|
844
|
+
// Re-add to queue with increased priority
|
|
845
|
+
this.commandQueue.unshift({
|
|
846
|
+
command: item.command,
|
|
847
|
+
resolve: item.resolve,
|
|
848
|
+
reject: item.reject,
|
|
849
|
+
priority: Math.max(1, item.priority - 1),
|
|
850
|
+
retryCount: item.retryCount
|
|
851
|
+
});
|
|
852
|
+
// Add extra delay before retry
|
|
853
|
+
await this.delay(500);
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
item.reject(error);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
this.lastCommandTime = Date.now();
|
|
860
|
+
}
|
|
861
|
+
this.isProcessing = false;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Calculate appropriate delay based on command priority and type
|
|
865
|
+
*/
|
|
866
|
+
calculateDelay(priority) {
|
|
867
|
+
// Priority 1-3: Heavy operations (asset creation, lighting build)
|
|
868
|
+
if (priority <= 3) {
|
|
869
|
+
return this.MAX_COMMAND_DELAY;
|
|
870
|
+
}
|
|
871
|
+
// Priority 4-6: Medium operations (actor spawning, material changes)
|
|
872
|
+
else if (priority <= 6) {
|
|
873
|
+
return 200;
|
|
874
|
+
}
|
|
875
|
+
// Priority 8: Stat commands - need special handling
|
|
876
|
+
else if (priority === 8) {
|
|
877
|
+
// Check time since last stat command to avoid FindConsoleObject warnings
|
|
878
|
+
const timeSinceLastStat = Date.now() - this.lastStatCommandTime;
|
|
879
|
+
if (timeSinceLastStat < this.STAT_COMMAND_DELAY) {
|
|
880
|
+
return this.STAT_COMMAND_DELAY;
|
|
881
|
+
}
|
|
882
|
+
this.lastStatCommandTime = Date.now();
|
|
883
|
+
return 150;
|
|
884
|
+
}
|
|
885
|
+
// Priority 7,9-10: Light operations (console commands, queries)
|
|
886
|
+
else {
|
|
887
|
+
return this.MIN_COMMAND_DELAY;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* SOLUTION 4: Enhanced Asset Creation
|
|
892
|
+
* Use Python scripting for complex asset creation that requires editor scripting
|
|
893
|
+
*/
|
|
894
|
+
async createComplexAsset(assetType, params) {
|
|
895
|
+
const assetCreators = {
|
|
896
|
+
'Material': 'MaterialFactoryNew',
|
|
897
|
+
'MaterialInstance': 'MaterialInstanceConstantFactoryNew',
|
|
898
|
+
'Blueprint': 'BlueprintFactory',
|
|
899
|
+
'AnimationBlueprint': 'AnimBlueprintFactory',
|
|
900
|
+
'ControlRig': 'ControlRigBlueprintFactory',
|
|
901
|
+
'NiagaraSystem': 'NiagaraSystemFactoryNew',
|
|
902
|
+
'NiagaraEmitter': 'NiagaraEmitterFactoryNew',
|
|
903
|
+
'LandscapeGrassType': 'LandscapeGrassTypeFactory',
|
|
904
|
+
'PhysicsAsset': 'PhysicsAssetFactory'
|
|
905
|
+
};
|
|
906
|
+
const factoryClass = assetCreators[assetType];
|
|
907
|
+
if (!factoryClass) {
|
|
908
|
+
throw new Error(`Unknown asset type: ${assetType}`);
|
|
909
|
+
}
|
|
910
|
+
const createParams = {
|
|
911
|
+
factory_class: factoryClass,
|
|
912
|
+
asset_class: `unreal.${assetType}`,
|
|
913
|
+
asset_name: params.name || `New${assetType}`,
|
|
914
|
+
package_path: params.path || '/Game/CreatedAssets',
|
|
915
|
+
...params
|
|
916
|
+
};
|
|
917
|
+
return this.executeEditorFunction('CREATE_ASSET', createParams);
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Start the command processor
|
|
921
|
+
*/
|
|
922
|
+
startCommandProcessor() {
|
|
923
|
+
// Periodic queue processing to handle any stuck commands
|
|
924
|
+
setInterval(() => {
|
|
925
|
+
if (!this.isProcessing && this.commandQueue.length > 0) {
|
|
926
|
+
this.processCommandQueue();
|
|
927
|
+
}
|
|
928
|
+
}, 1000);
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Helper delay function
|
|
932
|
+
*/
|
|
933
|
+
delay(ms) {
|
|
934
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Batch command execution with proper delays
|
|
938
|
+
*/
|
|
939
|
+
async executeBatch(commands) {
|
|
940
|
+
const results = [];
|
|
941
|
+
for (const cmd of commands) {
|
|
942
|
+
const result = await this.executeConsoleCommand(cmd.command);
|
|
943
|
+
results.push(result);
|
|
944
|
+
}
|
|
945
|
+
return results;
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Get safe console commands for common operations
|
|
949
|
+
*/
|
|
950
|
+
getSafeCommands() {
|
|
951
|
+
return {
|
|
952
|
+
// Health check (safe, no side effects)
|
|
953
|
+
'HealthCheck': 'echo MCP Server Health Check',
|
|
954
|
+
// Performance monitoring (safe)
|
|
955
|
+
'ShowFPS': 'stat unit', // Use 'stat unit' instead of 'stat fps'
|
|
956
|
+
'ShowMemory': 'stat memory',
|
|
957
|
+
'ShowGame': 'stat game',
|
|
958
|
+
'ShowRendering': 'stat scenerendering',
|
|
959
|
+
'ClearStats': 'stat none',
|
|
960
|
+
// Safe viewmodes
|
|
961
|
+
'ViewLit': 'viewmode lit',
|
|
962
|
+
'ViewUnlit': 'viewmode unlit',
|
|
963
|
+
'ViewWireframe': 'viewmode wireframe',
|
|
964
|
+
'ViewDetailLighting': 'viewmode detaillighting',
|
|
965
|
+
'ViewLightingOnly': 'viewmode lightingonly',
|
|
966
|
+
// Safe show flags
|
|
967
|
+
'ShowBounds': 'show bounds',
|
|
968
|
+
'ShowCollision': 'show collision',
|
|
969
|
+
'ShowNavigation': 'show navigation',
|
|
970
|
+
'ShowFog': 'show fog',
|
|
971
|
+
'ShowGrid': 'show grid',
|
|
972
|
+
// PIE controls
|
|
973
|
+
'PlayInEditor': 'play',
|
|
974
|
+
'StopPlay': 'stop',
|
|
975
|
+
'PausePlay': 'pause',
|
|
976
|
+
// Time control
|
|
977
|
+
'SlowMotion': 'slomo 0.5',
|
|
978
|
+
'NormalSpeed': 'slomo 1',
|
|
979
|
+
'FastForward': 'slomo 2',
|
|
980
|
+
// Camera controls
|
|
981
|
+
'CameraSpeed1': 'camspeed 1',
|
|
982
|
+
'CameraSpeed4': 'camspeed 4',
|
|
983
|
+
'CameraSpeed8': 'camspeed 8',
|
|
984
|
+
// Rendering quality (safe)
|
|
985
|
+
'LowQuality': 'sg.ViewDistanceQuality 0',
|
|
986
|
+
'MediumQuality': 'sg.ViewDistanceQuality 1',
|
|
987
|
+
'HighQuality': 'sg.ViewDistanceQuality 2',
|
|
988
|
+
'EpicQuality': 'sg.ViewDistanceQuality 3'
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
//# sourceMappingURL=unreal-bridge.js.map
|