unreal-engine-mcp-server 0.3.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/.env.production +1 -1
  2. package/.github/copilot-instructions.md +45 -0
  3. package/.github/workflows/publish-mcp.yml +1 -1
  4. package/README.md +22 -7
  5. package/dist/index.js +137 -46
  6. package/dist/prompts/index.d.ts +10 -3
  7. package/dist/prompts/index.js +186 -7
  8. package/dist/resources/actors.d.ts +19 -1
  9. package/dist/resources/actors.js +55 -64
  10. package/dist/resources/assets.d.ts +3 -2
  11. package/dist/resources/assets.js +117 -109
  12. package/dist/resources/levels.d.ts +21 -3
  13. package/dist/resources/levels.js +31 -56
  14. package/dist/tools/actors.d.ts +3 -14
  15. package/dist/tools/actors.js +246 -302
  16. package/dist/tools/animation.d.ts +57 -102
  17. package/dist/tools/animation.js +429 -450
  18. package/dist/tools/assets.d.ts +13 -2
  19. package/dist/tools/assets.js +58 -46
  20. package/dist/tools/audio.d.ts +22 -13
  21. package/dist/tools/audio.js +467 -121
  22. package/dist/tools/blueprint.d.ts +32 -13
  23. package/dist/tools/blueprint.js +699 -448
  24. package/dist/tools/build_environment_advanced.d.ts +0 -1
  25. package/dist/tools/build_environment_advanced.js +236 -87
  26. package/dist/tools/consolidated-tool-definitions.d.ts +232 -15
  27. package/dist/tools/consolidated-tool-definitions.js +124 -255
  28. package/dist/tools/consolidated-tool-handlers.js +749 -766
  29. package/dist/tools/debug.d.ts +72 -10
  30. package/dist/tools/debug.js +170 -36
  31. package/dist/tools/editor.d.ts +9 -2
  32. package/dist/tools/editor.js +30 -44
  33. package/dist/tools/foliage.d.ts +34 -15
  34. package/dist/tools/foliage.js +97 -107
  35. package/dist/tools/introspection.js +19 -21
  36. package/dist/tools/landscape.d.ts +1 -2
  37. package/dist/tools/landscape.js +311 -168
  38. package/dist/tools/level.d.ts +3 -28
  39. package/dist/tools/level.js +642 -192
  40. package/dist/tools/lighting.d.ts +14 -3
  41. package/dist/tools/lighting.js +236 -123
  42. package/dist/tools/materials.d.ts +25 -7
  43. package/dist/tools/materials.js +102 -79
  44. package/dist/tools/niagara.d.ts +10 -12
  45. package/dist/tools/niagara.js +74 -94
  46. package/dist/tools/performance.d.ts +12 -4
  47. package/dist/tools/performance.js +38 -79
  48. package/dist/tools/physics.d.ts +34 -10
  49. package/dist/tools/physics.js +364 -292
  50. package/dist/tools/rc.js +98 -24
  51. package/dist/tools/sequence.d.ts +1 -0
  52. package/dist/tools/sequence.js +146 -24
  53. package/dist/tools/ui.d.ts +31 -4
  54. package/dist/tools/ui.js +83 -66
  55. package/dist/tools/visual.d.ts +11 -0
  56. package/dist/tools/visual.js +245 -30
  57. package/dist/types/tool-types.d.ts +0 -6
  58. package/dist/types/tool-types.js +1 -8
  59. package/dist/unreal-bridge.d.ts +32 -2
  60. package/dist/unreal-bridge.js +621 -127
  61. package/dist/utils/elicitation.d.ts +57 -0
  62. package/dist/utils/elicitation.js +104 -0
  63. package/dist/utils/error-handler.d.ts +0 -33
  64. package/dist/utils/error-handler.js +4 -111
  65. package/dist/utils/http.d.ts +2 -22
  66. package/dist/utils/http.js +12 -75
  67. package/dist/utils/normalize.d.ts +4 -4
  68. package/dist/utils/normalize.js +15 -7
  69. package/dist/utils/python-output.d.ts +18 -0
  70. package/dist/utils/python-output.js +290 -0
  71. package/dist/utils/python.d.ts +2 -0
  72. package/dist/utils/python.js +4 -0
  73. package/dist/utils/response-validator.d.ts +6 -1
  74. package/dist/utils/response-validator.js +66 -13
  75. package/dist/utils/result-helpers.d.ts +27 -0
  76. package/dist/utils/result-helpers.js +147 -0
  77. package/dist/utils/safe-json.d.ts +0 -2
  78. package/dist/utils/safe-json.js +0 -43
  79. package/dist/utils/validation.d.ts +16 -0
  80. package/dist/utils/validation.js +70 -7
  81. package/mcp-config-example.json +2 -2
  82. package/package.json +11 -10
  83. package/server.json +37 -14
  84. package/src/index.ts +146 -50
  85. package/src/prompts/index.ts +211 -13
  86. package/src/resources/actors.ts +59 -44
  87. package/src/resources/assets.ts +123 -102
  88. package/src/resources/levels.ts +37 -47
  89. package/src/tools/actors.ts +269 -313
  90. package/src/tools/animation.ts +556 -539
  91. package/src/tools/assets.ts +59 -45
  92. package/src/tools/audio.ts +507 -113
  93. package/src/tools/blueprint.ts +778 -462
  94. package/src/tools/build_environment_advanced.ts +312 -106
  95. package/src/tools/consolidated-tool-definitions.ts +136 -267
  96. package/src/tools/consolidated-tool-handlers.ts +871 -795
  97. package/src/tools/debug.ts +179 -38
  98. package/src/tools/editor.ts +35 -37
  99. package/src/tools/foliage.ts +110 -104
  100. package/src/tools/introspection.ts +24 -22
  101. package/src/tools/landscape.ts +334 -181
  102. package/src/tools/level.ts +683 -182
  103. package/src/tools/lighting.ts +244 -123
  104. package/src/tools/materials.ts +114 -83
  105. package/src/tools/niagara.ts +87 -81
  106. package/src/tools/performance.ts +49 -88
  107. package/src/tools/physics.ts +393 -299
  108. package/src/tools/rc.ts +103 -25
  109. package/src/tools/sequence.ts +157 -30
  110. package/src/tools/ui.ts +101 -70
  111. package/src/tools/visual.ts +250 -29
  112. package/src/types/tool-types.ts +0 -9
  113. package/src/unreal-bridge.ts +658 -140
  114. package/src/utils/elicitation.ts +129 -0
  115. package/src/utils/error-handler.ts +4 -159
  116. package/src/utils/http.ts +16 -115
  117. package/src/utils/normalize.ts +20 -10
  118. package/src/utils/python-output.ts +351 -0
  119. package/src/utils/python.ts +3 -0
  120. package/src/utils/response-validator.ts +68 -17
  121. package/src/utils/result-helpers.ts +193 -0
  122. package/src/utils/safe-json.ts +0 -50
  123. package/src/utils/validation.ts +94 -7
  124. package/tests/run-unreal-tool-tests.mjs +720 -0
  125. package/tsconfig.json +2 -2
  126. package/dist/python-utils.d.ts +0 -29
  127. package/dist/python-utils.js +0 -54
  128. package/dist/tools/tool-definitions.d.ts +0 -4919
  129. package/dist/tools/tool-definitions.js +0 -1065
  130. package/dist/tools/tool-handlers.d.ts +0 -47
  131. package/dist/tools/tool-handlers.js +0 -863
  132. package/dist/types/index.d.ts +0 -323
  133. package/dist/types/index.js +0 -28
  134. package/dist/utils/cache-manager.d.ts +0 -64
  135. package/dist/utils/cache-manager.js +0 -176
  136. package/dist/utils/errors.d.ts +0 -133
  137. package/dist/utils/errors.js +0 -256
  138. package/src/python/editor_compat.py +0 -181
  139. package/src/python-utils.ts +0 -57
  140. package/src/tools/tool-definitions.ts +0 -1081
  141. package/src/tools/tool-handlers.ts +0 -973
  142. package/src/types/index.ts +0 -414
  143. package/src/utils/cache-manager.ts +0 -213
  144. package/src/utils/errors.ts +0 -312
package/src/tools/ui.ts CHANGED
@@ -1,22 +1,10 @@
1
1
  // UI tools for Unreal Engine
2
2
  import { UnrealBridge } from '../unreal-bridge.js';
3
+ import { bestEffortInterpretedText, interpretStandardResult } from '../utils/result-helpers.js';
3
4
 
4
5
  export class UITools {
5
6
  constructor(private bridge: UnrealBridge) {}
6
7
 
7
- // Execute console command
8
- private async _executeCommand(command: string) {
9
- return this.bridge.httpCall('/remote/object/call', 'PUT', {
10
- objectPath: '/Script/Engine.Default__KismetSystemLibrary',
11
- functionName: 'ExecuteConsoleCommand',
12
- parameters: {
13
- Command: command,
14
- SpecificPlayer: null
15
- },
16
- generateTransaction: false
17
- });
18
- }
19
-
20
8
  // Create widget blueprint
21
9
  async createWidget(params: {
22
10
  name: string;
@@ -57,10 +45,20 @@ except Exception as e:
57
45
  `.trim();
58
46
  try {
59
47
  const resp = await this.bridge.executePython(py);
60
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
61
- const m = out.match(/RESULT:({.*})/);
62
- if (m) { try { const parsed = JSON.parse(m[1]); return parsed.success ? { success: true, message: 'Widget blueprint created' } : { success: false, error: parsed.error }; } catch {} }
63
- return { success: true, message: 'Widget blueprint creation attempted' };
48
+ const interpreted = interpretStandardResult(resp, {
49
+ successMessage: 'Widget blueprint created',
50
+ failureMessage: 'Failed to create WidgetBlueprint'
51
+ });
52
+
53
+ if (interpreted.success) {
54
+ return { success: true, message: interpreted.message };
55
+ }
56
+
57
+ return {
58
+ success: false,
59
+ error: interpreted.error ?? 'Failed to create widget blueprint',
60
+ details: bestEffortInterpretedText(interpreted)
61
+ };
64
62
  } catch (e) {
65
63
  return { success: false, error: `Failed to create widget blueprint: ${e}` };
66
64
  }
@@ -78,7 +76,7 @@ except Exception as e:
78
76
  alignment?: [number, number];
79
77
  };
80
78
  }) {
81
- const commands = [];
79
+ const commands: string[] = [];
82
80
 
83
81
  commands.push(`AddWidgetComponent ${params.widgetName} ${params.componentType} ${params.componentName}`);
84
82
 
@@ -97,9 +95,7 @@ except Exception as e:
97
95
  }
98
96
  }
99
97
 
100
- for (const cmd of commands) {
101
- await this.bridge.executeConsoleCommand(cmd);
102
- }
98
+ await this.bridge.executeConsoleCommands(commands);
103
99
 
104
100
  return { success: true, message: `Component ${params.componentName} added to widget` };
105
101
  }
@@ -113,7 +109,7 @@ except Exception as e:
113
109
  color?: [number, number, number, number];
114
110
  fontFamily?: string;
115
111
  }) {
116
- const commands = [];
112
+ const commands: string[] = [];
117
113
 
118
114
  commands.push(`SetWidgetText ${params.widgetName}.${params.componentName} "${params.text}"`);
119
115
 
@@ -129,9 +125,7 @@ except Exception as e:
129
125
  commands.push(`SetWidgetFont ${params.widgetName}.${params.componentName} ${params.fontFamily}`);
130
126
  }
131
127
 
132
- for (const cmd of commands) {
133
- await this.bridge.executeConsoleCommand(cmd);
134
- }
128
+ await this.bridge.executeConsoleCommands(commands);
135
129
 
136
130
  return { success: true, message: 'Widget text updated' };
137
131
  }
@@ -144,7 +138,7 @@ except Exception as e:
144
138
  tint?: [number, number, number, number];
145
139
  sizeToContent?: boolean;
146
140
  }) {
147
- const commands = [];
141
+ const commands: string[] = [];
148
142
 
149
143
  commands.push(`SetWidgetImage ${params.widgetName}.${params.componentName} ${params.imagePath}`);
150
144
 
@@ -156,9 +150,7 @@ except Exception as e:
156
150
  commands.push(`SetWidgetSizeToContent ${params.widgetName}.${params.componentName} ${params.sizeToContent}`);
157
151
  }
158
152
 
159
- for (const cmd of commands) {
160
- await this.bridge.executeConsoleCommand(cmd);
161
- }
153
+ await this.bridge.executeConsoleCommands(commands);
162
154
 
163
155
  return { success: true, message: 'Widget image updated' };
164
156
  }
@@ -172,7 +164,7 @@ except Exception as e:
172
164
  size?: [number, number];
173
165
  }>;
174
166
  }) {
175
- const commands = [];
167
+ const commands: string[] = [];
176
168
 
177
169
  commands.push(`CreateHUDClass ${params.name}`);
178
170
 
@@ -183,9 +175,7 @@ except Exception as e:
183
175
  }
184
176
  }
185
177
 
186
- for (const cmd of commands) {
187
- await this.bridge.executeConsoleCommand(cmd);
188
- }
178
+ await this.bridge.executeConsoleCommands(commands);
189
179
 
190
180
  return { success: true, message: `HUD ${params.name} created` };
191
181
  }
@@ -197,11 +187,49 @@ except Exception as e:
197
187
  playerIndex?: number;
198
188
  }) {
199
189
  const playerIndex = params.playerIndex ?? 0;
190
+ const widgetName = params.widgetName?.trim();
191
+ if (!widgetName) {
192
+ return { success: false, error: 'widgetName is required' };
193
+ }
194
+
195
+ const verifyScript = `
196
+ import unreal, json
197
+ name = r"${widgetName}"
198
+ candidates = []
199
+ if name.startswith('/Game/'):
200
+ candidates.append(name)
201
+ else:
202
+ candidates.append(f"/Game/UI/Widgets/{name}")
203
+ candidates.append(f"/Game/{name}")
204
+
205
+ found_path = ''
206
+ for path in candidates:
207
+ if unreal.EditorAssetLibrary.does_asset_exist(path):
208
+ found_path = path
209
+ break
210
+
211
+ print('RESULT:' + json.dumps({'success': bool(found_path), 'path': found_path, 'candidates': candidates}))
212
+ `.trim();
213
+
214
+ const verify = await this.bridge.executePythonWithResult(verifyScript);
215
+ if (!verify?.success) {
216
+ return { success: false, error: `Widget asset not found for ${widgetName}` };
217
+ }
218
+
200
219
  const command = params.visible
201
- ? `ShowWidget ${params.widgetName} ${playerIndex}`
202
- : `HideWidget ${params.widgetName} ${playerIndex}`;
203
-
204
- return this.bridge.executeConsoleCommand(command);
220
+ ? `ShowWidget ${widgetName} ${playerIndex}`
221
+ : `HideWidget ${widgetName} ${playerIndex}`;
222
+
223
+ const raw = await this.bridge.executeConsoleCommand(command);
224
+ const summary = this.bridge.summarizeConsoleCommand(command, raw);
225
+
226
+ return {
227
+ success: true,
228
+ message: params.visible ? `Widget ${widgetName} show command issued` : `Widget ${widgetName} hide command issued`,
229
+ command: summary.command,
230
+ output: summary.output || undefined,
231
+ logLines: summary.logLines?.length ? summary.logLines : undefined
232
+ };
205
233
  }
206
234
 
207
235
  // Add widget to viewport
@@ -232,10 +260,20 @@ try:
232
260
  # Get the generated class from the widget blueprint
233
261
  widget_class = widget_bp.generated_class() if hasattr(widget_bp, 'generated_class') else widget_bp
234
262
 
235
- # Get the world and player controller
236
- world = unreal.EditorLevelLibrary.get_editor_world()
237
- if not world:
238
- print('RESULT:' + json.dumps({'success': False, 'error': 'No editor world available'}))
263
+ # Get the world and player controller via modern subsystems
264
+ world = None
265
+ try:
266
+ world = unreal.EditorUtilityLibrary.get_editor_world()
267
+ except Exception:
268
+ pass
269
+
270
+ if not world:
271
+ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
272
+ if editor_subsystem and hasattr(editor_subsystem, 'get_editor_world'):
273
+ world = editor_subsystem.get_editor_world()
274
+
275
+ if not world:
276
+ print('RESULT:' + json.dumps({'success': False, 'error': 'No editor world available. Start a PIE session or enable Editor Scripting Utilities.'}))
239
277
  else:
240
278
  # Try to get player controller
241
279
  try:
@@ -261,17 +299,20 @@ except Exception as e:
261
299
 
262
300
  try {
263
301
  const resp = await this.bridge.executePython(py);
264
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
265
- const m = out.match(/RESULT:({.*})/);
266
- if (m) {
267
- try {
268
- const parsed = JSON.parse(m[1]);
269
- return parsed.success
270
- ? { success: true, message: `Widget added to viewport with z-order ${zOrder}` }
271
- : { success: false, error: parsed.error };
272
- } catch {}
302
+ const interpreted = interpretStandardResult(resp, {
303
+ successMessage: `Widget added to viewport with z-order ${zOrder}`,
304
+ failureMessage: 'Failed to add widget to viewport'
305
+ });
306
+
307
+ if (interpreted.success) {
308
+ return { success: true, message: interpreted.message };
273
309
  }
274
- return { success: true, message: 'Widget add to viewport attempted' };
310
+
311
+ return {
312
+ success: false,
313
+ error: interpreted.error ?? 'Failed to add widget to viewport',
314
+ details: bestEffortInterpretedText(interpreted)
315
+ };
275
316
  } catch (e) {
276
317
  return { success: false, error: `Failed to add widget to viewport: ${e}` };
277
318
  }
@@ -297,7 +338,7 @@ except Exception as e:
297
338
  position?: [number, number];
298
339
  }>;
299
340
  }) {
300
- const commands = [];
341
+ const commands: string[] = [];
301
342
 
302
343
  commands.push(`CreateMenuWidget ${params.name} ${params.menuType}`);
303
344
 
@@ -308,9 +349,7 @@ except Exception as e:
308
349
  }
309
350
  }
310
351
 
311
- for (const cmd of commands) {
312
- await this.bridge.executeConsoleCommand(cmd);
313
- }
352
+ await this.bridge.executeConsoleCommands(commands);
314
353
 
315
354
  return { success: true, message: `Menu ${params.name} created` };
316
355
  }
@@ -329,7 +368,7 @@ except Exception as e:
329
368
  }>;
330
369
  }>;
331
370
  }) {
332
- const commands = [];
371
+ const commands: string[] = [];
333
372
 
334
373
  commands.push(`CreateWidgetAnimation ${params.widgetName} ${params.animationName} ${params.duration}`);
335
374
 
@@ -344,9 +383,7 @@ except Exception as e:
344
383
  }
345
384
  }
346
385
 
347
- for (const cmd of commands) {
348
- await this.bridge.executeConsoleCommand(cmd);
349
- }
386
+ await this.bridge.executeConsoleCommands(commands);
350
387
 
351
388
  return { success: true, message: `Animation ${params.animationName} created` };
352
389
  }
@@ -377,7 +414,7 @@ except Exception as e:
377
414
  margin?: [number, number, number, number];
378
415
  };
379
416
  }) {
380
- const commands = [];
417
+ const commands: string[] = [];
381
418
 
382
419
  if (params.style.backgroundColor) {
383
420
  commands.push(`SetWidgetBackgroundColor ${params.widgetName}.${params.componentName} ${params.style.backgroundColor.join(' ')}`);
@@ -399,9 +436,7 @@ except Exception as e:
399
436
  commands.push(`SetWidgetMargin ${params.widgetName}.${params.componentName} ${params.style.margin.join(' ')}`);
400
437
  }
401
438
 
402
- for (const cmd of commands) {
403
- await this.bridge.executeConsoleCommand(cmd);
404
- }
439
+ await this.bridge.executeConsoleCommands(commands);
405
440
 
406
441
  return { success: true, message: 'Widget style updated' };
407
442
  }
@@ -423,7 +458,7 @@ except Exception as e:
423
458
  showCursor?: boolean;
424
459
  lockCursor?: boolean;
425
460
  }) {
426
- const commands = [];
461
+ const commands: string[] = [];
427
462
 
428
463
  commands.push(`SetInputMode ${params.mode}`);
429
464
 
@@ -435,9 +470,7 @@ except Exception as e:
435
470
  commands.push(`SetMouseLockMode ${params.lockCursor}`);
436
471
  }
437
472
 
438
- for (const cmd of commands) {
439
- await this.bridge.executeConsoleCommand(cmd);
440
- }
473
+ await this.bridge.executeConsoleCommands(commands);
441
474
 
442
475
  return { success: true, message: `Input mode set to ${params.mode}` };
443
476
  }
@@ -475,9 +508,7 @@ except Exception as e:
475
508
  }
476
509
  }
477
510
 
478
- for (const cmd of commands) {
479
- await this.bridge.executeConsoleCommand(cmd);
480
- }
511
+ await this.bridge.executeConsoleCommands(commands);
481
512
 
482
513
  return { success: true, message: 'Drag and drop configured' };
483
514
  }
@@ -1,60 +1,281 @@
1
1
  import { UnrealBridge } from '../unreal-bridge.js';
2
2
  import { loadEnv } from '../types/env.js';
3
- import fs from 'fs';
3
+ import { Logger } from '../utils/logger.js';
4
+ import { coerceStringArray, interpretStandardResult } from '../utils/result-helpers.js';
5
+ import { promises as fs } from 'fs';
4
6
  import path from 'path';
5
7
 
6
8
  export class VisualTools {
7
9
  private env = loadEnv();
10
+ private log = new Logger('VisualTools');
8
11
  constructor(private bridge: UnrealBridge) {}
9
12
 
10
13
  // Take a screenshot of viewport (high res or standard). Returns path and base64 (truncated)
11
14
  async takeScreenshot(params: { resolution?: string }) {
12
15
  const res = params.resolution && /^\d+x\d+$/i.test(params.resolution) ? params.resolution : '';
13
- const cmd = res ? `HighResShot ${res}` : 'HighResShot';
16
+ const primaryCommand = res ? `HighResShot ${res}` : 'Shot';
17
+ const captureStartedAt = Date.now();
14
18
  try {
15
- await this.bridge.executeConsoleCommand(cmd);
16
- // Give the engine a moment to write the file
17
- await new Promise(r => setTimeout(r, 1200));
18
- const p = await this.findLatestScreenshot();
19
- if (!p) return { success: true, message: 'Screenshot triggered, but could not locate output file' };
19
+ const firstAttempt = await this.bridge.executeConsoleCommand(primaryCommand);
20
+ const firstSummary = this.bridge.summarizeConsoleCommand(primaryCommand, firstAttempt);
21
+
22
+ if (!res) {
23
+ const output = (firstSummary.output || '').toLowerCase();
24
+ const badInput = output.includes('bad input') || output.includes('unrecognized command');
25
+ const hasErrorLine = firstSummary.logLines?.some(line => /error:/i.test(line));
26
+ if (badInput || hasErrorLine) {
27
+ this.log.debug(`Screenshot primary command reported an error (${firstSummary.output || 'no output'})`);
28
+ }
29
+ }
30
+ // Give the engine a moment to write the file (UE can flush asynchronously)
31
+ await new Promise(r => setTimeout(r, 1200));
32
+ const p = await this.findLatestScreenshot(captureStartedAt);
33
+ if (!p) {
34
+ this.log.warn('Screenshot captured but output file was not found');
35
+ return { success: true, message: 'Screenshot triggered, but could not locate output file' };
36
+ }
20
37
  let b64: string | undefined;
38
+ let truncated = false;
39
+ let sizeBytes: number | undefined;
40
+ let mime: string | undefined;
21
41
  try {
22
- const buf = fs.readFileSync(p);
23
- // Limit to ~1MB to avoid huge responses
24
- const max = 1024 * 1024;
25
- b64 = buf.length > max ? buf.subarray(0, max).toString('base64') : buf.toString('base64');
42
+ const stat = await fs.stat(p);
43
+ sizeBytes = stat.size;
44
+ const max = 1024 * 1024; // 1 MiB cap for inline payloads
45
+ if (stat.size <= max) {
46
+ const buf = await fs.readFile(p);
47
+ b64 = buf.toString('base64');
48
+ } else {
49
+ truncated = true;
50
+ }
51
+ const ext = path.extname(p).toLowerCase();
52
+ if (ext === '.png') mime = 'image/png';
53
+ else if (ext === '.jpg' || ext === '.jpeg') mime = 'image/jpeg';
54
+ else if (ext === '.bmp') mime = 'image/bmp';
26
55
  } catch {}
27
- return { success: true, imagePath: p, imageBase64: b64 };
56
+ return {
57
+ success: true,
58
+ imagePath: p.replace(/\\/g, '/'),
59
+ imageMimeType: mime,
60
+ imageSizeBytes: sizeBytes,
61
+ imageBase64: b64,
62
+ imageBase64Truncated: truncated
63
+ };
28
64
  } catch (err: any) {
29
65
  return { success: false, error: String(err?.message || err) };
30
66
  }
31
67
  }
32
68
 
33
- private async findLatestScreenshot(): Promise<string | null> {
69
+ private async getEngineScreenshotDirectories(): Promise<string[]> {
70
+ const python = `
71
+ import unreal
72
+ import json
73
+ import os
74
+
75
+ result = {
76
+ "success": True,
77
+ "message": "",
78
+ "error": "",
79
+ "directories": [],
80
+ "warnings": [],
81
+ "details": []
82
+ }
83
+
84
+ def finalize():
85
+ data = dict(result)
86
+ if data.get("success"):
87
+ if not data.get("message"):
88
+ data["message"] = "Collected screenshot directories"
89
+ data.pop("error", None)
90
+ else:
91
+ if not data.get("error"):
92
+ data["error"] = data.get("message") or "Failed to collect screenshot directories"
93
+ if not data.get("message"):
94
+ data["message"] = data["error"]
95
+ if not data.get("warnings"):
96
+ data.pop("warnings", None)
97
+ if not data.get("details"):
98
+ data.pop("details", None)
99
+ if not data.get("directories"):
100
+ data.pop("directories", None)
101
+ return data
102
+
103
+ def add_path(candidate, note=None):
104
+ if not candidate:
105
+ return
106
+ try:
107
+ abs_path = os.path.abspath(os.path.normpath(candidate))
108
+ except Exception as normalize_error:
109
+ result["warnings"].append(f"Failed to normalize path {candidate}: {normalize_error}")
110
+ return
111
+ if abs_path not in result["directories"]:
112
+ result["directories"].append(abs_path)
113
+ if note:
114
+ result["details"].append(f"{note}: {abs_path}")
115
+ else:
116
+ result["details"].append(f"Discovered screenshot directory: {abs_path}")
117
+
118
+ try:
119
+ automation_dir = unreal.AutomationLibrary.get_screenshot_directory()
120
+ add_path(automation_dir, "Automation screenshot directory")
121
+ except Exception as automation_error:
122
+ result["warnings"].append(f"Automation screenshot directory unavailable: {automation_error}")
123
+
124
+ try:
125
+ project_saved = unreal.Paths.project_saved_dir()
126
+ add_path(project_saved, "Project Saved directory")
127
+ if project_saved:
128
+ add_path(os.path.join(project_saved, 'Screenshots'), "Project Saved screenshots")
129
+ add_path(os.path.join(project_saved, 'Screenshots', 'Windows'), "Project Saved Windows screenshots")
130
+ add_path(os.path.join(project_saved, 'Screenshots', 'WindowsEditor'), "Project Saved WindowsEditor screenshots")
131
+ try:
132
+ platform_name = unreal.SystemLibrary.get_platform_user_name()
133
+ if platform_name:
134
+ add_path(os.path.join(project_saved, 'Screenshots', platform_name), f"Project Saved screenshots for {platform_name}")
135
+ add_path(os.path.join(project_saved, 'Screenshots', f"{platform_name}Editor"), f"Project Saved editor screenshots for {platform_name}")
136
+ except Exception as platform_error:
137
+ result["warnings"].append(f"Failed to resolve platform-specific screenshot directories: {platform_error}")
138
+ except Exception as saved_error:
139
+ result["warnings"].append(f"Project Saved directory unavailable: {saved_error}")
140
+
141
+ try:
142
+ project_file = unreal.Paths.get_project_file_path()
143
+ if project_file:
144
+ project_dir = os.path.dirname(project_file)
145
+ add_path(os.path.join(project_dir, 'Saved', 'Screenshots'), "Project directory screenshots")
146
+ add_path(os.path.join(project_dir, 'Saved', 'Screenshots', 'Windows'), "Project directory Windows screenshots")
147
+ add_path(os.path.join(project_dir, 'Saved', 'Screenshots', 'WindowsEditor'), "Project directory WindowsEditor screenshots")
148
+ except Exception as project_error:
149
+ result["warnings"].append(f"Project directory screenshots unavailable: {project_error}")
150
+
151
+ if not result["directories"]:
152
+ result["warnings"].append("No screenshot directories discovered")
153
+
154
+ print('RESULT:' + json.dumps(finalize()))
155
+ `.trim()
156
+ .replace(/\r?\n/g, '\n');
157
+
158
+ try {
159
+ const response = await this.bridge.executePython(python);
160
+ const interpreted = interpretStandardResult(response, {
161
+ successMessage: 'Collected screenshot directories',
162
+ failureMessage: 'Failed to collect screenshot directories'
163
+ });
164
+
165
+ if (interpreted.details) {
166
+ for (const entry of interpreted.details) {
167
+ this.log.debug(entry);
168
+ }
169
+ }
170
+
171
+ if (interpreted.warnings) {
172
+ for (const warning of interpreted.warnings) {
173
+ this.log.debug(`Screenshot directory warning: ${warning}`);
174
+ }
175
+ }
176
+
177
+ const directories = coerceStringArray(interpreted.payload.directories);
178
+ if (directories?.length) {
179
+ return directories;
180
+ }
181
+
182
+ if (!interpreted.success && interpreted.error) {
183
+ this.log.warn(`Screenshot path probe failed: ${interpreted.error}`);
184
+ }
185
+
186
+ if (interpreted.rawText) {
187
+ try {
188
+ const fallback = JSON.parse(interpreted.rawText);
189
+ const fallbackDirs = coerceStringArray(fallback);
190
+ if (fallbackDirs?.length) {
191
+ return fallbackDirs;
192
+ }
193
+ } catch {}
194
+ }
195
+ } catch (err) {
196
+ this.log.debug('Screenshot path probe failed', err);
197
+ }
198
+ return [];
199
+ }
200
+
201
+ private async findLatestScreenshot(since?: number): Promise<string | null> {
34
202
  // Try env override, otherwise look in common UE Saved/Screenshots folder under project
35
203
  const candidates: string[] = [];
36
- if (this.env.UE_SCREENSHOT_DIR) candidates.push(this.env.UE_SCREENSHOT_DIR);
204
+ const seen = new Set<string>();
205
+ const addCandidate = (candidate?: string | null) => {
206
+ if (!candidate) return;
207
+ const normalized = path.isAbsolute(candidate)
208
+ ? path.normalize(candidate)
209
+ : path.resolve(candidate);
210
+ if (!seen.has(normalized)) {
211
+ seen.add(normalized);
212
+ candidates.push(normalized);
213
+ }
214
+ };
215
+
216
+ if (this.env.UE_SCREENSHOT_DIR) addCandidate(this.env.UE_SCREENSHOT_DIR);
37
217
  if (this.env.UE_PROJECT_PATH) {
38
218
  const projectDir = path.dirname(this.env.UE_PROJECT_PATH);
39
- candidates.push(path.join(projectDir, 'Saved', 'Screenshots'));
40
- candidates.push(path.join(projectDir, 'Saved', 'Screenshots', 'Windows'));
41
- candidates.push(path.join(projectDir, 'Saved', 'Screenshots', 'WindowsEditor'));
219
+ addCandidate(path.join(projectDir, 'Saved', 'Screenshots'));
220
+ addCandidate(path.join(projectDir, 'Saved', 'Screenshots', 'Windows'));
221
+ addCandidate(path.join(projectDir, 'Saved', 'Screenshots', 'WindowsEditor'));
42
222
  }
43
- // Fallback: common locations
44
- candidates.push(path.join(process.cwd(), 'Saved', 'Screenshots'));
45
- candidates.push(path.join(process.cwd(), 'Saved', 'Screenshots', 'Windows'));
46
- candidates.push(path.join(process.cwd(), 'Saved', 'Screenshots', 'WindowsEditor'));
47
- let latest: { path: string; mtime: number } | null = null;
48
- for (const c of candidates) {
223
+
224
+ const engineDirs = await this.getEngineScreenshotDirectories();
225
+ for (const dir of engineDirs) {
226
+ addCandidate(dir);
227
+ }
228
+
229
+ // Fallback: common locations relative to current working directory
230
+ addCandidate(path.join(process.cwd(), 'Saved', 'Screenshots'));
231
+ addCandidate(path.join(process.cwd(), 'Saved', 'Screenshots', 'Windows'));
232
+ addCandidate(path.join(process.cwd(), 'Saved', 'Screenshots', 'WindowsEditor'));
233
+
234
+ const searchDirs = new Set<string>();
235
+ const queue: string[] = [...candidates];
236
+ while (queue.length) {
237
+ const candidate = queue.pop();
238
+ if (!candidate || searchDirs.has(candidate)) continue;
239
+ searchDirs.add(candidate);
49
240
  try {
50
- const entries = fs.readdirSync(c).map(f => path.join(c, f));
51
- for (const fp of entries) {
52
- if (!/\.png$/i.test(fp)) continue;
53
- const st = fs.statSync(fp);
54
- if (!latest || st.mtimeMs > latest.mtime) latest = { path: fp, mtime: st.mtimeMs };
241
+ const entries = await fs.readdir(candidate, { withFileTypes: true });
242
+ for (const entry of entries) {
243
+ if (entry.isDirectory()) {
244
+ queue.push(path.join(candidate, entry.name));
245
+ }
55
246
  }
56
247
  } catch {}
57
248
  }
58
- return latest?.path || null;
249
+
250
+ let latest: { path: string; mtime: number } | null = null;
251
+ let latestSince: { path: string; mtime: number } | null = null;
252
+ const cutoff = since ? since - 2000 : undefined; // allow slight clock drift
253
+
254
+ for (const dirPath of searchDirs) {
255
+ let entries: string[];
256
+ try {
257
+ entries = await fs.readdir(dirPath);
258
+ } catch {
259
+ continue;
260
+ }
261
+ for (const entry of entries) {
262
+ const fp = path.join(dirPath, entry);
263
+ if (!/\.(png|jpg|jpeg|bmp)$/i.test(fp)) continue;
264
+ try {
265
+ const st = await fs.stat(fp);
266
+ const info = { path: fp, mtime: st.mtimeMs };
267
+ if (!latest || info.mtime > latest.mtime) {
268
+ latest = info;
269
+ }
270
+ if (cutoff !== undefined && st.mtimeMs >= cutoff) {
271
+ if (!latestSince || info.mtime > latestSince.mtime) {
272
+ latestSince = info;
273
+ }
274
+ }
275
+ } catch {}
276
+ }
277
+ }
278
+ const chosen = latestSince || latest;
279
+ return chosen?.path || null;
59
280
  }
60
281
  }
@@ -332,12 +332,3 @@ export type GetToolResponse<T extends ToolName> = ToolResponseMap[T];
332
332
  // Helper type for getting parameters by tool name
333
333
  export type GetToolParams<T extends keyof ConsolidatedToolParams> = ConsolidatedToolParams[T];
334
334
 
335
- // Export a type guard to check if a response is successful
336
- export function isSuccessResponse(response: BaseToolResponse): response is BaseToolResponse & { success: true } {
337
- return response.success === true;
338
- }
339
-
340
- // Export a type guard to check if a response has an error
341
- export function isErrorResponse(response: BaseToolResponse): response is BaseToolResponse & { error: string } {
342
- return response.success === false && typeof response.error === 'string';
343
- }