@baixfeng/godot-mcp-cli 1.0.11

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 (115) hide show
  1. package/README.md +187 -0
  2. package/addons/godot_mcp/command_handler.gd +161 -0
  3. package/addons/godot_mcp/command_handler.gd.uid +1 -0
  4. package/addons/godot_mcp/commands/base_command_processor.gd +221 -0
  5. package/addons/godot_mcp/commands/base_command_processor.gd.uid +1 -0
  6. package/addons/godot_mcp/commands/debugger_commands.gd +221 -0
  7. package/addons/godot_mcp/commands/debugger_commands.gd.uid +1 -0
  8. package/addons/godot_mcp/commands/editor_commands.gd +237 -0
  9. package/addons/godot_mcp/commands/editor_commands.gd.uid +1 -0
  10. package/addons/godot_mcp/commands/editor_script_commands.gd +365 -0
  11. package/addons/godot_mcp/commands/editor_script_commands.gd.uid +1 -0
  12. package/addons/godot_mcp/commands/input_commands.gd +337 -0
  13. package/addons/godot_mcp/commands/input_commands.gd.uid +1 -0
  14. package/addons/godot_mcp/commands/node_commands.gd +222 -0
  15. package/addons/godot_mcp/commands/node_commands.gd.uid +1 -0
  16. package/addons/godot_mcp/commands/project_commands.gd +298 -0
  17. package/addons/godot_mcp/commands/project_commands.gd.uid +1 -0
  18. package/addons/godot_mcp/commands/scene_commands.gd +337 -0
  19. package/addons/godot_mcp/commands/scene_commands.gd.uid +1 -0
  20. package/addons/godot_mcp/commands/script_commands.gd +349 -0
  21. package/addons/godot_mcp/commands/script_commands.gd.uid +1 -0
  22. package/addons/godot_mcp/mcp_asset_commands.gd +153 -0
  23. package/addons/godot_mcp/mcp_asset_commands.gd.uid +1 -0
  24. package/addons/godot_mcp/mcp_debug_output_publisher.gd +1669 -0
  25. package/addons/godot_mcp/mcp_debug_output_publisher.gd.uid +1 -0
  26. package/addons/godot_mcp/mcp_debugger_bridge.gd +1455 -0
  27. package/addons/godot_mcp/mcp_debugger_bridge.gd.uid +1 -0
  28. package/addons/godot_mcp/mcp_enhanced_commands.gd +1083 -0
  29. package/addons/godot_mcp/mcp_enhanced_commands.gd.uid +1 -0
  30. package/addons/godot_mcp/mcp_input_handler.gd +545 -0
  31. package/addons/godot_mcp/mcp_input_handler.gd.uid +1 -0
  32. package/addons/godot_mcp/mcp_runtime_debugger_bridge.gd +464 -0
  33. package/addons/godot_mcp/mcp_runtime_debugger_bridge.gd.uid +1 -0
  34. package/addons/godot_mcp/mcp_script_resource_commands.gd +165 -0
  35. package/addons/godot_mcp/mcp_script_resource_commands.gd.uid +1 -0
  36. package/addons/godot_mcp/mcp_server.gd +260 -0
  37. package/addons/godot_mcp/mcp_server.gd.uid +1 -0
  38. package/addons/godot_mcp/plugin.cfg +7 -0
  39. package/addons/godot_mcp/runtime_debugger.gd +81 -0
  40. package/addons/godot_mcp/runtime_debugger.gd.uid +1 -0
  41. package/addons/godot_mcp/ui/mcp_panel.gd +94 -0
  42. package/addons/godot_mcp/ui/mcp_panel.gd.uid +1 -0
  43. package/addons/godot_mcp/ui/mcp_panel.tscn +96 -0
  44. package/addons/godot_mcp/utils/node_utils.gd +82 -0
  45. package/addons/godot_mcp/utils/node_utils.gd.uid +1 -0
  46. package/addons/godot_mcp/utils/resource_utils.gd +81 -0
  47. package/addons/godot_mcp/utils/resource_utils.gd.uid +1 -0
  48. package/addons/godot_mcp/utils/script_utils.gd +114 -0
  49. package/addons/godot_mcp/utils/script_utils.gd.uid +1 -0
  50. package/addons/godot_mcp/websocket_server.gd +197 -0
  51. package/addons/godot_mcp/websocket_server.gd.uid +1 -0
  52. package/dist/cli.d.ts +2 -0
  53. package/dist/cli.js +561 -0
  54. package/dist/cli.js.map +1 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +156 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/resources/asset_resources.d.ts +29 -0
  59. package/dist/resources/asset_resources.js +145 -0
  60. package/dist/resources/asset_resources.js.map +1 -0
  61. package/dist/resources/debug_resources.d.ts +11 -0
  62. package/dist/resources/debug_resources.js +106 -0
  63. package/dist/resources/debug_resources.js.map +1 -0
  64. package/dist/resources/debugger_resources.d.ts +62 -0
  65. package/dist/resources/debugger_resources.js +201 -0
  66. package/dist/resources/debugger_resources.js.map +1 -0
  67. package/dist/resources/editor_resources.d.ts +47 -0
  68. package/dist/resources/editor_resources.js +155 -0
  69. package/dist/resources/editor_resources.js.map +1 -0
  70. package/dist/resources/project_resources.d.ts +33 -0
  71. package/dist/resources/project_resources.js +137 -0
  72. package/dist/resources/project_resources.js.map +1 -0
  73. package/dist/resources/scene_resources.d.ts +33 -0
  74. package/dist/resources/scene_resources.js +160 -0
  75. package/dist/resources/scene_resources.js.map +1 -0
  76. package/dist/resources/script_resources.d.ts +51 -0
  77. package/dist/resources/script_resources.js +203 -0
  78. package/dist/resources/script_resources.js.map +1 -0
  79. package/dist/tools/asset_tools.d.ts +5 -0
  80. package/dist/tools/asset_tools.js +125 -0
  81. package/dist/tools/asset_tools.js.map +1 -0
  82. package/dist/tools/debugger_tools.d.ts +2 -0
  83. package/dist/tools/debugger_tools.js +342 -0
  84. package/dist/tools/debugger_tools.js.map +1 -0
  85. package/dist/tools/editor_tools.d.ts +2 -0
  86. package/dist/tools/editor_tools.js +165 -0
  87. package/dist/tools/editor_tools.js.map +1 -0
  88. package/dist/tools/enhanced_tools.d.ts +5 -0
  89. package/dist/tools/enhanced_tools.js +706 -0
  90. package/dist/tools/enhanced_tools.js.map +1 -0
  91. package/dist/tools/input_tools.d.ts +2 -0
  92. package/dist/tools/input_tools.js +408 -0
  93. package/dist/tools/input_tools.js.map +1 -0
  94. package/dist/tools/node_tools.d.ts +5 -0
  95. package/dist/tools/node_tools.js +217 -0
  96. package/dist/tools/node_tools.js.map +1 -0
  97. package/dist/tools/project_tools.d.ts +5 -0
  98. package/dist/tools/project_tools.js +162 -0
  99. package/dist/tools/project_tools.js.map +1 -0
  100. package/dist/tools/scene_tools.d.ts +5 -0
  101. package/dist/tools/scene_tools.js +260 -0
  102. package/dist/tools/scene_tools.js.map +1 -0
  103. package/dist/tools/script_resource_tools.d.ts +5 -0
  104. package/dist/tools/script_resource_tools.js +5 -0
  105. package/dist/tools/script_resource_tools.js.map +1 -0
  106. package/dist/tools/script_tools.d.ts +5 -0
  107. package/dist/tools/script_tools.js +154 -0
  108. package/dist/tools/script_tools.js.map +1 -0
  109. package/dist/utils/godot_connection.d.ts +30 -0
  110. package/dist/utils/godot_connection.js +285 -0
  111. package/dist/utils/godot_connection.js.map +1 -0
  112. package/dist/utils/types.d.ts +16 -0
  113. package/dist/utils/types.js +2 -0
  114. package/dist/utils/types.js.map +1 -0
  115. package/package.json +58 -0
@@ -0,0 +1,1669 @@
1
+ @tool
2
+ class_name MCPDebugOutputPublisher
3
+ extends Node
4
+
5
+ const POLL_INTERVAL_SECONDS := 0.5
6
+ const OUTPUT_SCORE_THRESHOLD := 60
7
+ const OUTPUT_KEYWORDS := ["output", "console", "log", "stdout"]
8
+ const ERRORS_SCORE_THRESHOLD := 25
9
+ const ERRORS_KEYWORDS := [
10
+ "error", "errors", "warning", "warnings",
11
+ "stack", "trace", "gdscript", "issues"
12
+ ]
13
+ const STACK_TRACE_SCORE_THRESHOLD := 65
14
+ const STACK_TRACE_KEYWORDS := [
15
+ "stack", "trace", "callstack", "call_stack", "stack trace", "call stack"
16
+ ]
17
+ const STACK_FRAMES_SCORE_THRESHOLD := 65
18
+ const STACK_FRAMES_KEYWORDS := [
19
+ "stack frames",
20
+ "frames",
21
+ "frame list",
22
+ "call frames",
23
+ "call stack",
24
+ "stackframe",
25
+ "frames panel"
26
+ ]
27
+ const SUMMARY_VALUE_MAX_LEN := 160
28
+
29
+ var websocket_server: MCPWebSocketServer
30
+
31
+ var _subscribers: Dictionary = {}
32
+ var _elapsed := 0.0
33
+ var _last_length := 0
34
+ var _cached_output_control: Control = null
35
+ var _last_capture_source := "unset"
36
+ var _last_capture_detail := ""
37
+ var _last_capture_timestamp := 0
38
+ var _last_control_class := ""
39
+ var _last_control_path := ""
40
+ var _last_log_file_path := ""
41
+ var _last_control_search_summary := ""
42
+
43
+ func _ready() -> void:
44
+ set_process(false)
45
+
46
+ func subscribe(client_id: int) -> void:
47
+ _subscribers[client_id] = true
48
+ _initialize_baseline()
49
+ if not is_processing():
50
+ set_process(true)
51
+
52
+ func unsubscribe(client_id: int) -> void:
53
+ if _subscribers.erase(client_id) and _subscribers.is_empty():
54
+ set_process(false)
55
+
56
+ func unsubscribe_all() -> void:
57
+ _subscribers.clear()
58
+ set_process(false)
59
+
60
+ func _process(delta: float) -> void:
61
+ if _subscribers.is_empty():
62
+ set_process(false)
63
+ return
64
+
65
+ _elapsed += delta
66
+ if _elapsed < POLL_INTERVAL_SECONDS:
67
+ return
68
+ _elapsed = 0.0
69
+
70
+ _publish_incremental_frame()
71
+
72
+ func _initialize_baseline() -> void:
73
+ var current_text := _fetch_log_text()
74
+ if current_text == null:
75
+ _last_length = 0
76
+ else:
77
+ _last_length = current_text.length()
78
+
79
+ func _publish_incremental_frame() -> void:
80
+ var text := _fetch_log_text()
81
+ if text == null:
82
+ return
83
+
84
+ var reset := false
85
+ if text.length() < _last_length:
86
+ reset = true
87
+
88
+ var chunk := ""
89
+ if reset:
90
+ chunk = text
91
+ else:
92
+ if text.length() == _last_length:
93
+ return
94
+ chunk = text.substr(_last_length, text.length() - _last_length)
95
+
96
+ _last_length = text.length()
97
+
98
+ if chunk.is_empty() and not reset:
99
+ return
100
+
101
+ var lines: Array = []
102
+ if not chunk.is_empty():
103
+ lines = chunk.split("\n", false)
104
+ # Drop trailing empty line caused by split behaviour.
105
+ if lines.size() > 0 and String(lines.back()).is_empty():
106
+ lines.pop_back()
107
+
108
+ var payload := {
109
+ "event": "debug_output_frame",
110
+ "data": {
111
+ "timestamp": Time.get_ticks_msec(),
112
+ "chunk": chunk,
113
+ "lines": lines,
114
+ "reset": reset
115
+ }
116
+ }
117
+
118
+ for client_id in _subscribers.keys():
119
+ _send_event_to_client(int(client_id), payload)
120
+
121
+ func _send_event_to_client(client_id: int, payload: Dictionary) -> void:
122
+ if websocket_server == null:
123
+ return
124
+ websocket_server.send_event(client_id, payload)
125
+
126
+ func _fetch_log_text() -> String:
127
+ var control := _get_output_control()
128
+ var detail_notes: Array = []
129
+ var text := ""
130
+ _last_control_class = ""
131
+
132
+ if is_instance_valid(control):
133
+ _last_control_class = control.get_class()
134
+ if control.is_inside_tree():
135
+ _last_control_path = String(control.get_path())
136
+ else:
137
+ _last_control_path = ""
138
+
139
+ text = _extract_text_from_control(control)
140
+ if not text.is_empty():
141
+ _record_capture("control", "class=%s len=%d" % [_last_control_class, text.length()])
142
+ return text
143
+ detail_notes.append("control(%s) empty" % _last_control_class)
144
+ else:
145
+ _last_control_path = ""
146
+ detail_notes.append("control missing")
147
+
148
+ text = _fetch_debugger_log_text()
149
+ if not text.is_empty():
150
+ _record_capture("debugger", "len=%d" % text.length())
151
+ return text
152
+ detail_notes.append("debugger empty")
153
+
154
+ text = _fetch_log_file_text()
155
+ if not text.is_empty():
156
+ var path_info := _last_log_file_path if not _last_log_file_path.is_empty() else "unknown"
157
+ _record_capture("file", "path=%s len=%d" % [path_info, text.length()])
158
+ return text
159
+ detail_notes.append("log file missing")
160
+ if not _last_control_search_summary.is_empty():
161
+ detail_notes.append(_last_control_search_summary)
162
+
163
+ _record_capture("none", "; ".join(detail_notes))
164
+ return ""
165
+
166
+ func _record_capture(source: String, detail: String) -> void:
167
+ _last_capture_source = source
168
+ _last_capture_detail = detail
169
+ _last_capture_timestamp = Time.get_ticks_msec()
170
+
171
+ func _extract_text_from_control(control: Object) -> String:
172
+ if not is_instance_valid(control):
173
+ return ""
174
+
175
+ if control.has_method("get_parsed_text"):
176
+ return String(control.call("get_parsed_text"))
177
+ if control.has_method("get_text"):
178
+ return String(control.call("get_text"))
179
+ if control.has_method("get_full_text"):
180
+ return String(control.call("get_full_text"))
181
+ if control.has_method("get_line_count") and control.has_method("get_line"):
182
+ var lines: Array = []
183
+ var count := int(control.call("get_line_count"))
184
+ for i in range(count):
185
+ lines.append(String(control.call("get_line", i)))
186
+ return "\n".join(lines)
187
+ return ""
188
+
189
+ func _fetch_debugger_log_text() -> String:
190
+ if Engine.has_singleton("EditorDebuggerNode"):
191
+ var debugger = Engine.get_singleton("EditorDebuggerNode")
192
+ if debugger and debugger.has_method("get_log"):
193
+ return String(debugger.call("get_log"))
194
+ if has_node("/root/EditorNode/DebuggerPanel"):
195
+ var debugger_panel = get_node("/root/EditorNode/DebuggerPanel")
196
+ if debugger_panel and debugger_panel.has_method("get_output"):
197
+ return String(debugger_panel.call("get_output"))
198
+ return ""
199
+
200
+ func _fetch_log_file_text() -> String:
201
+ if not Engine.is_editor_hint():
202
+ return ""
203
+ var user_dir := ProjectSettings.globalize_path("user://")
204
+ _last_log_file_path = ""
205
+ var path_candidates: Array = [
206
+ "editor/editor.log",
207
+ "user/editor/editor.log",
208
+ "logs/editor.log",
209
+ "editor.log"
210
+ ]
211
+
212
+ for relative_path in path_candidates:
213
+ var candidate := user_dir.path_join(relative_path)
214
+ if FileAccess.file_exists(candidate):
215
+ _last_log_file_path = candidate
216
+ break
217
+
218
+ if _last_log_file_path.is_empty():
219
+ return ""
220
+
221
+ var file := FileAccess.open(_last_log_file_path, FileAccess.READ)
222
+ if file == null:
223
+ _last_log_file_path = ""
224
+ return ""
225
+
226
+ var content := file.get_as_text()
227
+ file.close()
228
+ return content
229
+
230
+ func _locate_output_control() -> Control:
231
+ var result := _locate_control_with_scoring(Callable(self, "_score_output_control"), OUTPUT_SCORE_THRESHOLD)
232
+ _last_control_search_summary = String(result.get("summary", ""))
233
+ return result.get("control")
234
+
235
+ func _locate_control_with_scoring(scoring_func: Callable, threshold: int) -> Dictionary:
236
+ var summary: Array = []
237
+
238
+ if not Engine.is_editor_hint():
239
+ return {
240
+ "control": null,
241
+ "summary": "editor_hint=false"
242
+ }
243
+ summary.append("editor_hint=true")
244
+
245
+ var base_control_result: Dictionary = {}
246
+ var base_control: Control = null
247
+
248
+ if Engine.has_meta("GodotMCPPlugin"):
249
+ var plugin = Engine.get_meta("GodotMCPPlugin")
250
+ if plugin and plugin is EditorPlugin:
251
+ var editor_interface = plugin.get_editor_interface()
252
+ if editor_interface and editor_interface.has_method("get_base_control"):
253
+ base_control = editor_interface.call("get_base_control")
254
+ if is_instance_valid(base_control):
255
+ summary.append("direct_base_control=valid")
256
+ base_control_result = _search_control_tree(base_control, scoring_func)
257
+ if not base_control_result.is_empty():
258
+ summary.append("direct_score=%d" % int(base_control_result.get("score", 0)))
259
+ summary.append("direct_visited=%d" % int(base_control_result.get("visited", 0)))
260
+ var direct_class := String(base_control_result.get("class", ""))
261
+ if not direct_class.is_empty():
262
+ summary.append("direct_class=%s" % direct_class)
263
+ var direct_path := String(base_control_result.get("path", ""))
264
+ if not direct_path.is_empty():
265
+ summary.append("direct_path=%s" % _summarize_summary_value(direct_path))
266
+ if int(base_control_result.get("score", 0)) >= threshold and base_control_result.get("control"):
267
+ return {
268
+ "control": base_control_result.get("control"),
269
+ "summary": "; ".join(summary)
270
+ }
271
+ else:
272
+ summary.append("direct_base_control=invalid")
273
+ else:
274
+ summary.append("direct_base_control=missing")
275
+ else:
276
+ summary.append("direct_plugin=invalid")
277
+ else:
278
+ summary.append("direct_plugin=missing")
279
+
280
+ var has_editor_node := Engine.has_singleton("EditorNode")
281
+ summary.append("editor_node_singleton=%s" % ("true" if has_editor_node else "false"))
282
+ if not has_editor_node:
283
+ return {"control": null, "summary": "; ".join(summary)}
284
+
285
+ var editor_node = Engine.get_singleton("EditorNode")
286
+ if editor_node == null:
287
+ summary.append("editor_node=null")
288
+ return {"control": null, "summary": "; ".join(summary)}
289
+ summary.append("editor_node=valid")
290
+
291
+ var search_roots: Array = []
292
+ var root_labels: Dictionary = {}
293
+ var root_ids: Dictionary = {}
294
+
295
+ if editor_node.has_method("get_log"):
296
+ var editor_log = editor_node.call("get_log")
297
+ var valid_log := is_instance_valid(editor_log)
298
+ summary.append("get_log=%s" % ("valid" if valid_log else "invalid"))
299
+ if valid_log:
300
+ _register_control_root(search_roots, root_labels, root_ids, editor_log, "editor_log")
301
+ else:
302
+ summary.append("get_log=missing")
303
+
304
+ if editor_node.has_method("get_gui_base"):
305
+ var gui_base = editor_node.call("get_gui_base")
306
+ var valid_gui := is_instance_valid(gui_base)
307
+ summary.append("get_gui_base=%s" % ("valid" if valid_gui else "invalid"))
308
+ if valid_gui:
309
+ _register_control_root(search_roots, root_labels, root_ids, gui_base, "gui_base")
310
+ else:
311
+ summary.append("get_gui_base=missing")
312
+
313
+ if is_instance_valid(base_control):
314
+ _register_control_root(search_roots, root_labels, root_ids, base_control, "base_control")
315
+
316
+ if Engine.has_meta("GodotMCPPlugin"):
317
+ var plugin_again = Engine.get_meta("GodotMCPPlugin")
318
+ if plugin_again and plugin_again is EditorPlugin:
319
+ var editor_interface_again = plugin_again.get_editor_interface()
320
+ if editor_interface_again:
321
+ if editor_interface_again.has_method("get_editor_main_screen"):
322
+ var main_screen = editor_interface_again.call("get_editor_main_screen")
323
+ var valid_main := is_instance_valid(main_screen)
324
+ summary.append("plugin_main_screen=%s" % ("valid" if valid_main else "invalid"))
325
+ if valid_main:
326
+ _register_control_root(search_roots, root_labels, root_ids, main_screen, "main_screen")
327
+ else:
328
+ summary.append("plugin_main_screen=missing")
329
+ else:
330
+ summary.append("editor_interface=null")
331
+ else:
332
+ summary.append("mcp_plugin=invalid")
333
+ else:
334
+ summary.append("mcp_plugin=missing")
335
+
336
+ var scene_tree := get_tree()
337
+ if scene_tree:
338
+ var tree_root := scene_tree.get_root()
339
+ var valid_root := is_instance_valid(tree_root)
340
+ summary.append("scene_tree_root=%s" % ("valid" if valid_root else "invalid"))
341
+ if valid_root:
342
+ _register_control_root(search_roots, root_labels, root_ids, tree_root, "scene_tree_root")
343
+ else:
344
+ summary.append("scene_tree=null")
345
+
346
+ var total_visited := 0
347
+ var best_control: Control = null
348
+ var best_score := 0
349
+ var best_label := ""
350
+ var best_class := ""
351
+ var best_path := ""
352
+
353
+ for root in search_roots:
354
+ var result := _search_control_tree(root, scoring_func)
355
+ var id = root.get_instance_id()
356
+ var label := String(root_labels.get(id, "root"))
357
+ total_visited += int(result.get("visited", 0))
358
+ var score := int(result.get("score", 0))
359
+ if score > best_score:
360
+ best_score = score
361
+ best_control = result.get("control")
362
+ best_label = label
363
+ best_class = String(result.get("class", ""))
364
+ best_path = String(result.get("path", ""))
365
+
366
+ summary.append("search_roots=%d" % search_roots.size())
367
+ summary.append("search_total_visited=%d" % total_visited)
368
+ if best_score > 0:
369
+ summary.append("search_best_score=%d" % best_score)
370
+ if not best_label.is_empty():
371
+ summary.append("search_best_label=%s" % best_label)
372
+ if not best_class.is_empty():
373
+ summary.append("search_best_class=%s" % best_class)
374
+ if not best_path.is_empty():
375
+ summary.append("search_best_path=%s" % _summarize_summary_value(best_path))
376
+ else:
377
+ summary.append("search_best_score=0")
378
+
379
+ var summary_text := "; ".join(summary)
380
+ if best_control and best_score >= threshold:
381
+ return {
382
+ "control": best_control,
383
+ "summary": summary_text
384
+ }
385
+
386
+ return {
387
+ "control": null,
388
+ "summary": summary_text
389
+ }
390
+
391
+ func _search_control_tree(root: Node, scoring_func: Callable = Callable()) -> Dictionary:
392
+ var visited: Dictionary = {}
393
+ var queue: Array = []
394
+ var visited_count := 0
395
+ var best_control: Control = null
396
+ var best_score := 0
397
+ var best_class := ""
398
+ var best_path := ""
399
+
400
+ if is_instance_valid(root):
401
+ queue.append(root)
402
+
403
+ while queue.size() > 0:
404
+ var candidate = queue.pop_front()
405
+ if not (candidate is Node):
406
+ continue
407
+
408
+ var node: Node = candidate
409
+ var instance_id := node.get_instance_id()
410
+ if visited.has(instance_id):
411
+ continue
412
+ visited[instance_id] = true
413
+ visited_count += 1
414
+
415
+ if node is Control:
416
+ var control_node: Control = node
417
+ var score := 0
418
+ if scoring_func.is_valid():
419
+ score = int(scoring_func.call(control_node))
420
+ else:
421
+ score = _score_output_control(control_node)
422
+ if score > best_score:
423
+ best_score = score
424
+ best_control = control_node
425
+ best_class = control_node.get_class()
426
+ if control_node.is_inside_tree():
427
+ best_path = String(control_node.get_path())
428
+ else:
429
+ best_path = String(control_node.name) if control_node.has_method("get_name") else "<detached>"
430
+
431
+ for child in node.get_children():
432
+ queue.append(child)
433
+
434
+ return {
435
+ "control": best_control,
436
+ "score": best_score,
437
+ "class": best_class,
438
+ "path": best_path,
439
+ "visited": visited_count
440
+ }
441
+
442
+ func _register_control_root(search_roots: Array, root_labels: Dictionary, root_ids: Dictionary, node: Node, label: String) -> void:
443
+ if not is_instance_valid(node):
444
+ return
445
+ var id := node.get_instance_id()
446
+ if root_ids.has(id):
447
+ return
448
+ root_ids[id] = true
449
+ search_roots.append(node)
450
+ root_labels[id] = label
451
+
452
+ func _summarize_summary_value(text: String) -> String:
453
+ if text.is_empty():
454
+ return text
455
+ if text.length() <= SUMMARY_VALUE_MAX_LEN:
456
+ return text
457
+ return text.substr(0, SUMMARY_VALUE_MAX_LEN - 3) + "..."
458
+
459
+ func _score_output_control(node: Control) -> int:
460
+ return _score_control_with_keywords(node, OUTPUT_KEYWORDS)
461
+
462
+ func _score_errors_control(node: Control) -> int:
463
+ return _score_control_with_keywords(node, ERRORS_KEYWORDS)
464
+
465
+ func _score_control_with_keywords(node: Control, keywords: Array) -> int:
466
+ if node == null:
467
+ return 0
468
+
469
+ var score := 0
470
+
471
+ if node.is_class("RichTextLabel"):
472
+ score = max(score, 30)
473
+ elif node.is_class("TextEdit") or node.is_class("CodeEdit"):
474
+ score = max(score, 25)
475
+ else:
476
+ return 0
477
+
478
+ if _has_editor_log_ancestor(node):
479
+ score = max(score, 100)
480
+
481
+ var node_name := String(node.name).to_lower()
482
+ for keyword in keywords:
483
+ if node_name.find(keyword) != -1:
484
+ score = max(score, 80)
485
+ break
486
+
487
+ var theme_type := ""
488
+ if node.has_method("get_theme_type_variation"):
489
+ theme_type = String(node.call("get_theme_type_variation")).to_lower()
490
+ for keyword in keywords:
491
+ if theme_type.find(keyword) != -1:
492
+ score = max(score, 75)
493
+ break
494
+
495
+ var parent := node.get_parent()
496
+ var depth := 0
497
+ while parent and depth < 6:
498
+ var parent_name := String(parent.name).to_lower()
499
+ for keyword in keywords:
500
+ if parent_name.find(keyword) != -1:
501
+ var parent_score := 70 - depth * 5
502
+ if parent_score > score:
503
+ score = parent_score
504
+ break
505
+ if parent.is_class("EditorLog"):
506
+ score = max(score, 95)
507
+ parent = parent.get_parent()
508
+ depth += 1
509
+
510
+ if node.is_inside_tree():
511
+ var path_lower := String(node.get_path()).to_lower()
512
+ for keyword in keywords:
513
+ if path_lower.find(keyword) != -1:
514
+ score = max(score, 70)
515
+ break
516
+
517
+ return score
518
+
519
+ func _has_editor_log_ancestor(node: Node) -> bool:
520
+ var current := node.get_parent()
521
+ while current:
522
+ if current.is_class("EditorLog"):
523
+ return true
524
+ current = current.get_parent()
525
+ return false
526
+
527
+ func _get_output_control() -> Control:
528
+ if is_instance_valid(_cached_output_control):
529
+ return _cached_output_control
530
+ _cached_output_control = _locate_output_control()
531
+ return _cached_output_control
532
+
533
+ func get_full_log_text() -> String:
534
+ return _fetch_log_text()
535
+
536
+ func clear_log_output() -> Dictionary:
537
+ var diagnostics := {
538
+ "timestamp": Time.get_ticks_msec(),
539
+ "attempts": []
540
+ }
541
+
542
+ if not Engine.is_editor_hint():
543
+ diagnostics["error"] = "not_in_editor"
544
+ return {
545
+ "cleared": false,
546
+ "method": "editor_only",
547
+ "diagnostics": diagnostics
548
+ }
549
+
550
+ var cleared := false
551
+ var method_used := ""
552
+
553
+ var editor_node = Engine.get_singleton("EditorNode") if Engine.has_singleton("EditorNode") else null
554
+ if editor_node and editor_node.has_method("get_log"):
555
+ var editor_log = editor_node.call("get_log")
556
+ if is_instance_valid(editor_log):
557
+ diagnostics["attempts"].append("editor_log")
558
+ if editor_log.has_method("clear"):
559
+ editor_log.call("clear")
560
+ cleared = true
561
+ method_used = "editor_log_clear"
562
+ elif editor_log.has_method("set_text"):
563
+ editor_log.call("set_text", "")
564
+ cleared = true
565
+ method_used = "editor_log_set_text"
566
+
567
+ if not cleared and Engine.has_singleton("EditorDebuggerNode"):
568
+ var debugger_node = Engine.get_singleton("EditorDebuggerNode")
569
+ if debugger_node and debugger_node.has_method("clear_log"):
570
+ diagnostics["attempts"].append("editor_debugger_node")
571
+ debugger_node.call("clear_log")
572
+ cleared = true
573
+ method_used = "editor_debugger_clear_log"
574
+
575
+ if not cleared:
576
+ var control := _get_output_control()
577
+ if is_instance_valid(control):
578
+ diagnostics["attempts"].append("output_control_%s" % control.get_class())
579
+ if control.has_method("clear"):
580
+ control.call("clear")
581
+ cleared = true
582
+ method_used = "control_clear"
583
+ elif control.has_method("set_text"):
584
+ control.call("set_text", "")
585
+ cleared = true
586
+ method_used = "control_set_text"
587
+
588
+ if cleared:
589
+ _last_length = 0
590
+ _broadcast_log_reset()
591
+
592
+ diagnostics["cleared"] = cleared
593
+ diagnostics["method"] = method_used
594
+
595
+ return {
596
+ "cleared": cleared,
597
+ "method": method_used,
598
+ "diagnostics": diagnostics
599
+ }
600
+
601
+ func _broadcast_log_reset() -> void:
602
+ if _subscribers.is_empty() or websocket_server == null:
603
+ return
604
+
605
+ var payload := {
606
+ "event": "debug_output_frame",
607
+ "data": {
608
+ "timestamp": Time.get_ticks_msec(),
609
+ "chunk": "",
610
+ "lines": [],
611
+ "reset": true
612
+ }
613
+ }
614
+
615
+ for client_id in _subscribers.keys():
616
+ _send_event_to_client(int(client_id), payload)
617
+
618
+ func get_errors_panel_snapshot() -> Dictionary:
619
+ return _capture_errors_tab_text()
620
+
621
+ func clear_errors_panel() -> Dictionary:
622
+ var diagnostics := {
623
+ "timestamp": Time.get_ticks_msec(),
624
+ "attempts": []
625
+ }
626
+
627
+ if not Engine.is_editor_hint():
628
+ diagnostics["error"] = "not_in_editor"
629
+ return {
630
+ "cleared": false,
631
+ "method": "editor_only",
632
+ "diagnostics": diagnostics
633
+ }
634
+
635
+ var cleared := false
636
+ var method_used := ""
637
+
638
+ var search_roots := _gather_editor_search_roots()
639
+ var tab_info := {}
640
+ for root in search_roots:
641
+ tab_info = _find_errors_tab_control(root)
642
+ if tab_info.has("control"):
643
+ break
644
+
645
+ if not tab_info.has("control"):
646
+ diagnostics["error"] = "errors_tab_not_found"
647
+ return {
648
+ "cleared": false,
649
+ "method": "not_found",
650
+ "diagnostics": diagnostics
651
+ }
652
+
653
+ var tab_control: Control = tab_info.get("control")
654
+ if not is_instance_valid(tab_control):
655
+ diagnostics["error"] = "tab_control_invalid"
656
+ return {
657
+ "cleared": false,
658
+ "method": "invalid_control",
659
+ "diagnostics": diagnostics
660
+ }
661
+
662
+ diagnostics["tab_title"] = tab_info.get("tab_title", "")
663
+ diagnostics["attempts"].append("found_tab=%s" % tab_info.get("tab_title", ""))
664
+
665
+ var tree := _find_descendant_tree(tab_control)
666
+ if is_instance_valid(tree):
667
+ diagnostics["attempts"].append("tree_control")
668
+ if tree.has_method("clear"):
669
+ tree.call("clear")
670
+ cleared = true
671
+ method_used = "tree_clear"
672
+ diagnostics["tree_path"] = String(tree.get_path()) if tree.is_inside_tree() else ""
673
+
674
+ if not cleared and tab_control.has_method("clear"):
675
+ diagnostics["attempts"].append("tab_control_clear")
676
+ tab_control.call("clear")
677
+ cleared = true
678
+ method_used = "tab_control_clear"
679
+
680
+ diagnostics["cleared"] = cleared
681
+ diagnostics["method"] = method_used
682
+
683
+ return {
684
+ "cleared": cleared,
685
+ "method": method_used,
686
+ "diagnostics": diagnostics
687
+ }
688
+
689
+ func get_stack_trace_snapshot(session_id: int = -1) -> Dictionary:
690
+ return _capture_stack_trace_panel(session_id)
691
+
692
+ func get_stack_frames_snapshot(session_id: int = -1) -> Dictionary:
693
+ return _capture_stack_frames_panel(session_id)
694
+
695
+ func _gather_editor_search_roots() -> Array:
696
+ var search_roots: Array = []
697
+
698
+ var editor_node = Engine.get_singleton("EditorNode") if Engine.has_singleton("EditorNode") else null
699
+ if editor_node:
700
+ if editor_node.has_method("get_log"):
701
+ var editor_log = editor_node.call("get_log")
702
+ if is_instance_valid(editor_log):
703
+ search_roots.append(editor_log)
704
+ search_roots.append(editor_node)
705
+
706
+ var plugin = Engine.get_meta("GodotMCPPlugin") if Engine.has_meta("GodotMCPPlugin") else null
707
+ if plugin and plugin is EditorPlugin:
708
+ var editor_interface = plugin.get_editor_interface()
709
+ if editor_interface and editor_interface.has_method("get_base_control"):
710
+ var base_control = editor_interface.call("get_base_control")
711
+ if is_instance_valid(base_control):
712
+ search_roots.append(base_control)
713
+
714
+ var scene_tree := get_tree()
715
+ if scene_tree:
716
+ var tree_root := scene_tree.get_root()
717
+ if is_instance_valid(tree_root):
718
+ search_roots.append(tree_root)
719
+
720
+ if Engine.has_singleton("EditorDebuggerNode"):
721
+ var debugger_node = Engine.get_singleton("EditorDebuggerNode")
722
+ if debugger_node and debugger_node is Node and is_instance_valid(debugger_node):
723
+ search_roots.append(debugger_node)
724
+ if debugger_node.has_method("get_gui_base"):
725
+ var debugger_gui = debugger_node.call("get_gui_base")
726
+ if is_instance_valid(debugger_gui):
727
+ search_roots.append(debugger_gui)
728
+
729
+ var debugger_panel := get_node_or_null("/root/EditorNode/DebuggerPanel")
730
+ if debugger_panel and is_instance_valid(debugger_panel):
731
+ search_roots.append(debugger_panel)
732
+
733
+ return search_roots
734
+
735
+ func _capture_errors_tab_text() -> Dictionary:
736
+ var diagnostics := {
737
+ "source": "errors_tab_lookup",
738
+ "timestamp": Time.get_ticks_msec(),
739
+ "control_found": false,
740
+ "search_summary": ""
741
+ }
742
+
743
+ if not Engine.is_editor_hint():
744
+ diagnostics["error"] = "not_in_editor"
745
+ return {
746
+ "text": "",
747
+ "lines": [],
748
+ "line_count": 0,
749
+ "diagnostics": diagnostics
750
+ }
751
+
752
+ var search_roots := _gather_editor_search_roots()
753
+
754
+ var aggregated_summary: Array = []
755
+ var tab_info := {}
756
+ for root in search_roots:
757
+ tab_info = _find_errors_tab_control(root)
758
+ var summary := String(tab_info.get("summary", ""))
759
+ if not summary.is_empty():
760
+ aggregated_summary.append(summary)
761
+ if tab_info.has("control"):
762
+ break
763
+
764
+ diagnostics["search_summary"] = " | ".join(aggregated_summary) if aggregated_summary.size() > 0 else ""
765
+
766
+ if tab_info.is_empty() or not tab_info.has("control"):
767
+ diagnostics["error"] = "errors_tab_not_found"
768
+ return {
769
+ "text": "",
770
+ "lines": [],
771
+ "line_count": 0,
772
+ "diagnostics": diagnostics
773
+ }
774
+
775
+ var tab_control: Control = tab_info.get("control")
776
+ if not is_instance_valid(tab_control):
777
+ diagnostics["error"] = "tab_control_invalid"
778
+ return {
779
+ "text": "",
780
+ "lines": [],
781
+ "line_count": 0,
782
+ "diagnostics": diagnostics
783
+ }
784
+
785
+ diagnostics["control_found"] = true
786
+ diagnostics["tab_title"] = tab_info.get("tab_title", "")
787
+ if tab_control.is_inside_tree():
788
+ diagnostics["control_path"] = String(tab_control.get_path())
789
+ else:
790
+ diagnostics["control_path"] = ""
791
+
792
+ var tree := _find_descendant_tree(tab_control)
793
+ var lines: Array = []
794
+ var text := ""
795
+
796
+ if tree:
797
+ diagnostics["tree_path"] = String(tree.get_path()) if tree.is_inside_tree() else ""
798
+ lines = _collect_tree_lines(tree)
799
+ text = "\n".join(lines)
800
+ else:
801
+ text = _extract_text_from_control(tab_control)
802
+ if not text.is_empty():
803
+ lines = text.split("\n", false)
804
+
805
+ return {
806
+ "text": text,
807
+ "lines": lines,
808
+ "line_count": lines.size(),
809
+ "diagnostics": diagnostics
810
+ }
811
+
812
+ func _capture_stack_trace_panel(session_id: int) -> Dictionary:
813
+ var diagnostics := {
814
+ "source": "stack_trace_lookup",
815
+ "timestamp": Time.get_ticks_msec(),
816
+ "control_found": false,
817
+ "search_summary": ""
818
+ }
819
+
820
+ if not Engine.is_editor_hint():
821
+ diagnostics["error"] = "not_in_editor"
822
+ return {
823
+ "text": "",
824
+ "lines": [],
825
+ "line_count": 0,
826
+ "frames": [],
827
+ "diagnostics": diagnostics
828
+ }
829
+
830
+ var search_roots := _gather_editor_search_roots()
831
+ if search_roots.is_empty():
832
+ diagnostics["error"] = "search_roots_empty"
833
+ return {
834
+ "text": "",
835
+ "lines": [],
836
+ "line_count": 0,
837
+ "frames": [],
838
+ "diagnostics": diagnostics
839
+ }
840
+
841
+ var aggregated_summary: Array = []
842
+ var panel_info := {}
843
+ for root in search_roots:
844
+ panel_info = _find_stack_trace_control(root)
845
+ var summary := String(panel_info.get("summary", ""))
846
+ if not summary.is_empty():
847
+ aggregated_summary.append(summary)
848
+ if panel_info.has("control"):
849
+ break
850
+
851
+ if aggregated_summary.size() > 0:
852
+ diagnostics["search_summary"] = " | ".join(aggregated_summary)
853
+
854
+ if panel_info.is_empty() or not panel_info.has("control"):
855
+ diagnostics["error"] = "stack_trace_panel_not_found"
856
+ return {
857
+ "text": "",
858
+ "lines": [],
859
+ "line_count": 0,
860
+ "frames": [],
861
+ "diagnostics": diagnostics
862
+ }
863
+
864
+ var panel_control: Control = panel_info.get("control")
865
+ if not is_instance_valid(panel_control):
866
+ diagnostics["error"] = "panel_control_invalid"
867
+ return {
868
+ "text": "",
869
+ "lines": [],
870
+ "line_count": 0,
871
+ "frames": [],
872
+ "diagnostics": diagnostics
873
+ }
874
+
875
+ diagnostics["control_found"] = true
876
+ diagnostics["control_class"] = panel_control.get_class()
877
+ diagnostics["control_path"] = String(panel_control.get_path()) if panel_control.is_inside_tree() else ""
878
+ if panel_info.has("tab_title"):
879
+ diagnostics["tab_title"] = panel_info.get("tab_title")
880
+ if panel_info.has("score"):
881
+ diagnostics["match_score"] = panel_info.get("score")
882
+ if panel_info.has("fallback_source"):
883
+ diagnostics["fallback_source"] = panel_info.get("fallback_source")
884
+ diagnostics["panel_children"] = _summarize_control_structure(panel_control, 2, 64)
885
+
886
+ var lines: Array = []
887
+ var frames: Array = []
888
+ var text := ""
889
+
890
+ var tree: Tree = null
891
+ if panel_info.has("tree"):
892
+ tree = panel_info.get("tree")
893
+ if not is_instance_valid(tree) and panel_control is Tree:
894
+ tree = panel_control
895
+
896
+ if is_instance_valid(tree):
897
+ diagnostics["tree_path"] = String(tree.get_path()) if tree.is_inside_tree() else ""
898
+ diagnostics["tree_item_count"] = _count_tree_items(tree)
899
+ lines = _collect_tree_lines(tree)
900
+ text = "\n".join(lines)
901
+ frames = _collect_stack_tree_frames(tree)
902
+ if lines.is_empty() and text.is_empty():
903
+ var fallback_text_control := _find_descendant_text_control(panel_control)
904
+ if is_instance_valid(fallback_text_control):
905
+ diagnostics["text_control_path"] = String(fallback_text_control.get_path()) if fallback_text_control.is_inside_tree() else ""
906
+ diagnostics["text_control_class"] = fallback_text_control.get_class()
907
+ text = _extract_text_from_control(fallback_text_control)
908
+ if not text.is_empty():
909
+ lines = text.split("\n", false)
910
+ frames = _derive_frames_from_lines(lines)
911
+ if frames.is_empty():
912
+ diagnostics["fallback_text_source"] = "stack_panel_text_control"
913
+ else:
914
+ var text_control := _find_descendant_text_control(panel_control)
915
+ var capture_control: Control = panel_control
916
+ if is_instance_valid(text_control):
917
+ capture_control = text_control
918
+ diagnostics["text_control_path"] = String(text_control.get_path()) if text_control.is_inside_tree() else ""
919
+ diagnostics["text_control_class"] = text_control.get_class()
920
+
921
+ text = _extract_text_from_control(capture_control)
922
+ if not text.is_empty():
923
+ lines = text.split("\n", false)
924
+ frames = _derive_frames_from_lines(lines)
925
+
926
+ return {
927
+ "text": text,
928
+ "lines": lines,
929
+ "line_count": lines.size(),
930
+ "frames": frames,
931
+ "diagnostics": diagnostics
932
+ }
933
+
934
+ func _capture_stack_frames_panel(session_id: int) -> Dictionary:
935
+ var diagnostics := {
936
+ "source": "stack_frames_lookup",
937
+ "timestamp": Time.get_ticks_msec(),
938
+ "control_found": false,
939
+ "search_summary": ""
940
+ }
941
+
942
+ if not Engine.is_editor_hint():
943
+ diagnostics["error"] = "not_in_editor"
944
+ return {
945
+ "text": "",
946
+ "lines": [],
947
+ "line_count": 0,
948
+ "frames": [],
949
+ "diagnostics": diagnostics
950
+ }
951
+
952
+ var search_roots := _gather_editor_search_roots()
953
+ if search_roots.is_empty():
954
+ diagnostics["error"] = "search_roots_empty"
955
+ return {
956
+ "text": "",
957
+ "lines": [],
958
+ "line_count": 0,
959
+ "frames": [],
960
+ "diagnostics": diagnostics
961
+ }
962
+
963
+ var aggregated_summary: Array = []
964
+ var panel_info := {}
965
+ for root in search_roots:
966
+ panel_info = _find_stack_frames_control(root)
967
+ var summary := String(panel_info.get("summary", ""))
968
+ if not summary.is_empty():
969
+ aggregated_summary.append(summary)
970
+ if panel_info.has("control"):
971
+ break
972
+
973
+ if aggregated_summary.size() > 0:
974
+ diagnostics["search_summary"] = " | ".join(aggregated_summary)
975
+
976
+ if panel_info.is_empty() or not panel_info.has("control"):
977
+ diagnostics["error"] = "stack_frames_panel_not_found"
978
+ return {
979
+ "text": "",
980
+ "lines": [],
981
+ "line_count": 0,
982
+ "frames": [],
983
+ "diagnostics": diagnostics
984
+ }
985
+
986
+ var panel_control: Control = panel_info.get("control")
987
+ if not is_instance_valid(panel_control):
988
+ diagnostics["error"] = "panel_control_invalid"
989
+ return {
990
+ "text": "",
991
+ "lines": [],
992
+ "line_count": 0,
993
+ "frames": [],
994
+ "diagnostics": diagnostics
995
+ }
996
+
997
+ diagnostics["control_found"] = true
998
+ diagnostics["control_class"] = panel_control.get_class()
999
+ diagnostics["control_path"] = String(panel_control.get_path()) if panel_control.is_inside_tree() else ""
1000
+ if panel_info.has("tab_title"):
1001
+ diagnostics["tab_title"] = panel_info.get("tab_title")
1002
+ if panel_info.has("score"):
1003
+ diagnostics["match_score"] = panel_info.get("score")
1004
+ diagnostics["panel_children"] = _summarize_control_structure(panel_control, 2, 64)
1005
+
1006
+ var lines: Array = []
1007
+ var text := ""
1008
+ var frames: Array = []
1009
+
1010
+ var tree: Tree = null
1011
+ if panel_info.has("tree"):
1012
+ tree = panel_info.get("tree")
1013
+ if not is_instance_valid(tree) and panel_control is Tree:
1014
+ tree = panel_control
1015
+
1016
+ if is_instance_valid(tree):
1017
+ diagnostics["tree_path"] = String(tree.get_path()) if tree.is_inside_tree() else ""
1018
+ diagnostics["tree_item_count"] = _count_tree_items(tree)
1019
+ lines = _collect_tree_lines(tree)
1020
+ text = "\n".join(lines)
1021
+ frames = _collect_stack_tree_frames(tree)
1022
+ if lines.is_empty() and frames.is_empty():
1023
+ var text_control := _find_descendant_text_control(panel_control)
1024
+ if is_instance_valid(text_control):
1025
+ diagnostics["text_control_path"] = String(text_control.get_path()) if text_control.is_inside_tree() else ""
1026
+ diagnostics["text_control_class"] = text_control.get_class()
1027
+ text = _extract_text_from_control(text_control)
1028
+ if not text.is_empty():
1029
+ lines = text.split("\n", false)
1030
+ frames = _derive_frames_from_lines(lines)
1031
+ if frames.is_empty():
1032
+ diagnostics["fallback_text_source"] = "stack_frames_text_control"
1033
+ else:
1034
+ var text_control := _find_descendant_text_control(panel_control)
1035
+ var capture_control: Control = panel_control
1036
+ if is_instance_valid(text_control):
1037
+ capture_control = text_control
1038
+ diagnostics["text_control_path"] = String(text_control.get_path()) if text_control.is_inside_tree() else ""
1039
+ diagnostics["text_control_class"] = text_control.get_class()
1040
+
1041
+ text = _extract_text_from_control(capture_control)
1042
+ if not text.is_empty():
1043
+ lines = text.split("\n", false)
1044
+ frames = _derive_frames_from_lines(lines)
1045
+
1046
+ return {
1047
+ "text": text,
1048
+ "lines": lines,
1049
+ "line_count": lines.size(),
1050
+ "frames": frames,
1051
+ "diagnostics": diagnostics
1052
+ }
1053
+
1054
+ func _find_errors_tab_control(root: Node) -> Dictionary:
1055
+ var queue: Array = []
1056
+ var summary := []
1057
+ if is_instance_valid(root):
1058
+ queue.append(root)
1059
+ else:
1060
+ return {}
1061
+
1062
+ var visited := 0
1063
+
1064
+ while queue.size() > 0:
1065
+ var candidate = queue.pop_front()
1066
+ if not is_instance_valid(candidate):
1067
+ continue
1068
+ visited += 1
1069
+
1070
+ if candidate is TabContainer:
1071
+ var tab_container: TabContainer = candidate
1072
+ var tab_count := 0
1073
+ if tab_container.has_method("get_tab_count"):
1074
+ tab_count = tab_container.get_tab_count()
1075
+ else:
1076
+ tab_count = tab_container.get_child_count()
1077
+
1078
+ for i in range(tab_count):
1079
+ var title := ""
1080
+ if tab_container.has_method("get_tab_title"):
1081
+ title = String(tab_container.get_tab_title(i))
1082
+ var title_lower := title.to_lower()
1083
+ if title_lower.find("error") != -1:
1084
+ var tab_control: Control = null
1085
+ if tab_container.has_method("get_tab_control"):
1086
+ tab_control = tab_container.get_tab_control(i)
1087
+ if not is_instance_valid(tab_control):
1088
+ # Fallback: try to get nth child
1089
+ if i < tab_container.get_child_count():
1090
+ var child = tab_container.get_child(i)
1091
+ if child is Control:
1092
+ tab_control = child
1093
+
1094
+ if is_instance_valid(tab_control):
1095
+ tab_control = _unwrap_tab_content(tab_control)
1096
+ summary.append("tab_found=%s" % title)
1097
+ summary.append("visited=%d" % visited)
1098
+ return {
1099
+ "control": tab_control,
1100
+ "tab_title": title,
1101
+ "summary": "; ".join(summary)
1102
+ }
1103
+ for child in candidate.get_children():
1104
+ if child is Node:
1105
+ queue.append(child)
1106
+
1107
+ return {"summary": "; ".join(summary)}
1108
+
1109
+ func _find_stack_trace_control(root: Node) -> Dictionary:
1110
+ var queue: Array = []
1111
+ var summary := []
1112
+ if is_instance_valid(root):
1113
+ queue.append(root)
1114
+ else:
1115
+ return {}
1116
+
1117
+ var visited := 0
1118
+ var max_nodes := 8192
1119
+
1120
+ while queue.size() > 0 and visited < max_nodes:
1121
+ var candidate = queue.pop_front()
1122
+ if not is_instance_valid(candidate):
1123
+ continue
1124
+ visited += 1
1125
+
1126
+ if candidate is TabContainer:
1127
+ var tab_container: TabContainer = candidate
1128
+ var tab_count := 0
1129
+ if tab_container.has_method("get_tab_count"):
1130
+ tab_count = tab_container.get_tab_count()
1131
+ else:
1132
+ tab_count = tab_container.get_child_count()
1133
+ for i in range(tab_count):
1134
+ var title := ""
1135
+ if tab_container.has_method("get_tab_title"):
1136
+ title = String(tab_container.get_tab_title(i))
1137
+ var title_lower := title.to_lower()
1138
+ for keyword in STACK_TRACE_KEYWORDS:
1139
+ if title_lower.find(keyword) != -1:
1140
+ var tab_control: Control = null
1141
+ if tab_container.has_method("get_tab_control"):
1142
+ tab_control = tab_container.get_tab_control(i)
1143
+ if not is_instance_valid(tab_control) and i < tab_container.get_child_count():
1144
+ var child = tab_container.get_child(i)
1145
+ if child is Control:
1146
+ tab_control = child
1147
+
1148
+ if is_instance_valid(tab_control):
1149
+ tab_control = _unwrap_tab_content(tab_control)
1150
+ var tree := _find_descendant_tree(tab_control)
1151
+ summary.append("tab_found=%s" % title)
1152
+ summary.append("visited=%d" % visited)
1153
+ return {
1154
+ "control": tab_control,
1155
+ "tree": tree,
1156
+ "tab_title": title,
1157
+ "summary": "; ".join(summary)
1158
+ }
1159
+ break
1160
+
1161
+ if candidate is Control:
1162
+ var control_score := _score_stack_trace_candidate(candidate)
1163
+ if control_score >= STACK_TRACE_SCORE_THRESHOLD:
1164
+ var matched_control: Control = candidate
1165
+ var tree_control: Tree = candidate if candidate is Tree else _find_descendant_tree(candidate)
1166
+ var info := {
1167
+ "control": matched_control,
1168
+ "score": control_score,
1169
+ "summary": "score=%d name=%s class=%s visited=%d" % [control_score, candidate.name, candidate.get_class(), visited]
1170
+ }
1171
+ if tree_control:
1172
+ info["tree"] = tree_control
1173
+ return info
1174
+
1175
+ for child in candidate.get_children():
1176
+ if child is Node:
1177
+ queue.append(child)
1178
+
1179
+ summary.append("visited=%d" % visited)
1180
+ return {"summary": "; ".join(summary)}
1181
+
1182
+ func _find_stack_frames_control(root: Node) -> Dictionary:
1183
+ var queue: Array = []
1184
+ var summary := []
1185
+ if is_instance_valid(root):
1186
+ queue.append(root)
1187
+ else:
1188
+ return {}
1189
+
1190
+ var visited := 0
1191
+ var max_nodes := 8192
1192
+
1193
+ while queue.size() > 0 and visited < max_nodes:
1194
+ var candidate = queue.pop_front()
1195
+ if not is_instance_valid(candidate):
1196
+ continue
1197
+ visited += 1
1198
+
1199
+ if candidate is TabContainer:
1200
+ var tab_container: TabContainer = candidate
1201
+ var tab_count := 0
1202
+ if tab_container.has_method("get_tab_count"):
1203
+ tab_count = tab_container.get_tab_count()
1204
+ else:
1205
+ tab_count = tab_container.get_child_count()
1206
+ for i in range(tab_count):
1207
+ var title := ""
1208
+ if tab_container.has_method("get_tab_title"):
1209
+ title = String(tab_container.get_tab_title(i))
1210
+ var title_lower := title.to_lower()
1211
+ if _title_matches_stack_frames(title_lower):
1212
+ var tab_control: Control = null
1213
+ if tab_container.has_method("get_tab_control"):
1214
+ tab_control = tab_container.get_tab_control(i)
1215
+ if not is_instance_valid(tab_control) and i < tab_container.get_child_count():
1216
+ var child = tab_container.get_child(i)
1217
+ if child is Control:
1218
+ tab_control = child
1219
+
1220
+ if is_instance_valid(tab_control):
1221
+ tab_control = _unwrap_tab_content(tab_control)
1222
+ var tree := _find_descendant_tree(tab_control)
1223
+ summary.append("tab_found=%s" % title)
1224
+ summary.append("visited=%d" % visited)
1225
+ return {
1226
+ "control": tab_control,
1227
+ "tree": tree,
1228
+ "tab_title": title,
1229
+ "summary": "; ".join(summary)
1230
+ }
1231
+
1232
+ if candidate is Control:
1233
+ var control_score := _score_stack_frames_candidate(candidate)
1234
+ if control_score >= STACK_FRAMES_SCORE_THRESHOLD:
1235
+ var matched_control: Control = candidate
1236
+ var tree_control: Tree = candidate if candidate is Tree else _find_descendant_tree(candidate)
1237
+ var info := {
1238
+ "control": matched_control,
1239
+ "score": control_score,
1240
+ "summary": "score=%d name=%s class=%s visited=%d" % [control_score, candidate.name, candidate.get_class(), visited]
1241
+ }
1242
+ if tree_control:
1243
+ info["tree"] = tree_control
1244
+ return info
1245
+
1246
+ for child in candidate.get_children():
1247
+ if child is Node:
1248
+ queue.append(child)
1249
+
1250
+ summary.append("visited=%d" % visited)
1251
+ var fallback_info := _find_stack_trace_control(root)
1252
+ if fallback_info.has("control"):
1253
+ fallback_info["fallback_source"] = "stack_trace_lookup"
1254
+ var combined_summary := "; ".join(summary)
1255
+ if fallback_info.has("summary") and not String(fallback_info["summary"]).is_empty():
1256
+ combined_summary = "%s | %s" % [combined_summary, fallback_info["summary"]]
1257
+ fallback_info["summary"] = combined_summary
1258
+ return fallback_info
1259
+ return {"summary": "; ".join(summary)}
1260
+
1261
+ func _score_stack_trace_candidate(node: Control) -> int:
1262
+ var score := 0
1263
+ var name_lower := String(node.name).to_lower()
1264
+ for keyword in STACK_TRACE_KEYWORDS:
1265
+ if name_lower.find(keyword) != -1:
1266
+ score += 35
1267
+ break
1268
+
1269
+ var class_lower := node.get_class().to_lower()
1270
+ if class_lower.find("stack") != -1 or class_lower.find("trace") != -1:
1271
+ score += 20
1272
+
1273
+ if node is Tree:
1274
+ score += 20
1275
+
1276
+ var parent := node.get_parent()
1277
+ var depth := 0
1278
+ while parent and depth < 4:
1279
+ var parent_name := String(parent.name).to_lower()
1280
+ if parent_name.find("debug") != -1 or parent_name.find("stack") != -1:
1281
+ score += 10
1282
+ break
1283
+ parent = parent.get_parent()
1284
+ depth += 1
1285
+
1286
+ if node.is_inside_tree():
1287
+ var path_lower := String(node.get_path()).to_lower()
1288
+ for keyword in STACK_TRACE_KEYWORDS:
1289
+ if path_lower.find(keyword) != -1:
1290
+ score += 10
1291
+ break
1292
+
1293
+ return score
1294
+
1295
+ func _score_stack_frames_candidate(node: Control) -> int:
1296
+ var score := 0
1297
+ var name_lower := String(node.name).to_lower()
1298
+ if name_lower.find("frame") != -1 and name_lower.find("stack") != -1:
1299
+ score += 35
1300
+ else:
1301
+ for keyword in STACK_FRAMES_KEYWORDS:
1302
+ if name_lower.find(keyword) != -1:
1303
+ score += 35
1304
+ break
1305
+
1306
+ var class_lower := node.get_class().to_lower()
1307
+ if class_lower.find("frame") != -1 and class_lower.find("stack") != -1:
1308
+ score += 20
1309
+
1310
+ if node is Tree:
1311
+ score += 20
1312
+
1313
+ var parent := node.get_parent()
1314
+ var depth := 0
1315
+ while parent and depth < 4:
1316
+ var parent_name := String(parent.name).to_lower()
1317
+ if parent_name.find("frame") != -1 and parent_name.find("stack") != -1:
1318
+ score += 10
1319
+ break
1320
+ if parent_name.find("call stack") != -1:
1321
+ score += 10
1322
+ break
1323
+ parent = parent.get_parent()
1324
+ depth += 1
1325
+
1326
+ if node.is_inside_tree():
1327
+ var path_lower := String(node.get_path()).to_lower()
1328
+ if path_lower.find("frame") != -1 and path_lower.find("stack") != -1:
1329
+ score += 10
1330
+ elif path_lower.find("call_stack") != -1 or path_lower.find("call stack") != -1:
1331
+ score += 10
1332
+
1333
+ return score
1334
+
1335
+ func _title_matches_stack_frames(title_lower: String) -> bool:
1336
+ if title_lower.is_empty():
1337
+ return false
1338
+ if title_lower.find("stack frames") != -1 or title_lower.find("stack frame") != -1:
1339
+ return true
1340
+ if title_lower.find("call stack") != -1:
1341
+ return true
1342
+ if title_lower.find("frame") != -1 and title_lower.find("stack") != -1:
1343
+ return true
1344
+ for keyword in STACK_FRAMES_KEYWORDS:
1345
+ if title_lower.find(keyword) != -1:
1346
+ return true
1347
+ return false
1348
+
1349
+ func _collect_stack_tree_frames(tree: Tree) -> Array:
1350
+ var frames: Array = []
1351
+ if not is_instance_valid(tree):
1352
+ return frames
1353
+
1354
+ var root := tree.get_root()
1355
+ if not root:
1356
+ return frames
1357
+
1358
+ var item := root.get_first_child()
1359
+ var fallback_index := 0
1360
+ while item:
1361
+ frames.append(_build_stack_frame_from_item(item, tree.columns, fallback_index))
1362
+ fallback_index += 1
1363
+ item = item.get_next()
1364
+
1365
+ return frames
1366
+
1367
+ func _build_stack_frame_from_item(item: TreeItem, column_count: int, fallback_index: int) -> Dictionary:
1368
+ var columns: Array = []
1369
+ for col in range(column_count):
1370
+ columns.append(String(item.get_text(col)))
1371
+
1372
+ var function_name := ""
1373
+ var location := ""
1374
+
1375
+ if columns.size() >= 2:
1376
+ function_name = columns[1]
1377
+ if columns.size() >= 3:
1378
+ location = columns[2]
1379
+ elif columns.size() >= 2:
1380
+ location = columns[1]
1381
+
1382
+ var script_path := ""
1383
+ var line_number := -1
1384
+
1385
+ if not location.is_empty():
1386
+ var split := location.rsplit(":", false, 1)
1387
+ if split.size() == 2:
1388
+ var potential_line := split[1].strip_edges()
1389
+ if potential_line.is_valid_int():
1390
+ line_number = int(potential_line)
1391
+ script_path = split[0]
1392
+
1393
+ var index_value := fallback_index
1394
+ if columns.size() > 0:
1395
+ var index_text := String(columns[0]).strip_edges()
1396
+ if index_text.begins_with("#"):
1397
+ index_text = index_text.substr(1).strip_edges()
1398
+ if index_text.is_valid_int():
1399
+ index_value = int(index_text)
1400
+
1401
+ return {
1402
+ "index": index_value,
1403
+ "function": function_name,
1404
+ "location": location,
1405
+ "script": script_path,
1406
+ "line": line_number,
1407
+ "columns": columns
1408
+ }
1409
+
1410
+ func _derive_frames_from_lines(lines: Array) -> Array:
1411
+ var frames: Array = []
1412
+ var i := 0
1413
+ var fallback_index := 0
1414
+ while i < lines.size():
1415
+ var current_line := String(lines[i]).strip_edges()
1416
+ if current_line.is_empty():
1417
+ i += 1
1418
+ continue
1419
+
1420
+ var consumed_lines := 1
1421
+ var parsed := _parse_stack_line(current_line, fallback_index)
1422
+
1423
+ if parsed.is_empty():
1424
+ if current_line.begins_with("res://") or current_line.begins_with("user://"):
1425
+ var script_path := current_line
1426
+ var line_number := -1
1427
+ if i + 1 < lines.size():
1428
+ var next_line := String(lines[i + 1]).strip_edges()
1429
+ var prefix := "Line "
1430
+ if next_line.begins_with(prefix):
1431
+ var maybe_number := next_line.substr(prefix.length()).strip_edges()
1432
+ if maybe_number.is_valid_int():
1433
+ line_number = int(maybe_number)
1434
+ consumed_lines = 2
1435
+ var location := script_path
1436
+ if line_number >= 0:
1437
+ location = "%s:%d" % [script_path, line_number]
1438
+ parsed = {
1439
+ "index": fallback_index,
1440
+ "function": "",
1441
+ "location": location,
1442
+ "script": script_path,
1443
+ "line": line_number,
1444
+ "columns": [script_path]
1445
+ }
1446
+ if not parsed.is_empty():
1447
+ frames.append(parsed)
1448
+ fallback_index += 1
1449
+ i += consumed_lines
1450
+
1451
+ return frames
1452
+
1453
+ func _parse_stack_line(line: String, fallback_index: int) -> Dictionary:
1454
+ var trimmed := line.strip_edges()
1455
+ if trimmed.is_empty():
1456
+ return {}
1457
+
1458
+ var index_value := fallback_index
1459
+ if trimmed.begins_with("#"):
1460
+ var after_hash := trimmed.substr(1).strip_edges()
1461
+ var parts := after_hash.split(" ", false)
1462
+ if parts.size() > 0:
1463
+ var index_text := parts[0].strip_edges()
1464
+ if index_text.is_valid_int():
1465
+ index_value = int(index_text)
1466
+
1467
+ var script_path := ""
1468
+ var line_number := -1
1469
+ var colon_index := trimmed.rfind(":")
1470
+ if colon_index != -1 and colon_index + 1 < trimmed.length():
1471
+ var maybe_line := trimmed.substr(colon_index + 1).strip_edges()
1472
+ if maybe_line.is_valid_int():
1473
+ line_number = int(maybe_line)
1474
+ script_path = trimmed.substr(0, colon_index)
1475
+ return {
1476
+ "index": index_value,
1477
+ "function": "",
1478
+ "location": trimmed,
1479
+ "script": script_path,
1480
+ "line": line_number,
1481
+ "columns": [trimmed]
1482
+ }
1483
+
1484
+ return {}
1485
+
1486
+ func _unwrap_tab_content(control: Control) -> Control:
1487
+ if not is_instance_valid(control):
1488
+ return control
1489
+
1490
+ # Godot editor wraps tab content inside MarginContainer -> VBox/HBox -> actual content.
1491
+ # Try to find the deepest Control that actually holds text.
1492
+ var current := control
1493
+ var safety := 0
1494
+
1495
+ while safety < 5 and current.get_child_count() == 1:
1496
+ var child = current.get_child(0)
1497
+ if child is Control:
1498
+ current = child
1499
+ safety += 1
1500
+ else:
1501
+ break
1502
+
1503
+ # If we ended up on a container with multiple children, try to pick the TextEdit/RichTextLabel.
1504
+ if current.get_child_count() > 1:
1505
+ for child in current.get_children():
1506
+ if child is Control and (_is_text_display_control(child)):
1507
+ return child
1508
+
1509
+ return current
1510
+
1511
+ func _is_text_display_control(control: Control) -> bool:
1512
+ if not is_instance_valid(control):
1513
+ return false
1514
+ if control.is_class("TextEdit") or control.is_class("CodeEdit") or control.is_class("RichTextLabel"):
1515
+ return true
1516
+ return false
1517
+
1518
+ func _find_descendant_tree(root: Node, max_nodes: int = 8192) -> Tree:
1519
+ if not is_instance_valid(root):
1520
+ return null
1521
+ var queue: Array = [root]
1522
+ var visited := 0
1523
+ while queue.size() > 0 and visited < max_nodes:
1524
+ var candidate = queue.pop_front()
1525
+ visited += 1
1526
+ if not is_instance_valid(candidate):
1527
+ continue
1528
+ if candidate is Tree:
1529
+ return candidate
1530
+ for child in candidate.get_children():
1531
+ if child is Node:
1532
+ queue.append(child)
1533
+ return null
1534
+
1535
+ func _find_descendant_text_control(root: Node, max_nodes: int = 4096) -> Control:
1536
+ if not is_instance_valid(root):
1537
+ return null
1538
+ var queue: Array = [root]
1539
+ var visited := 0
1540
+ while queue.size() > 0 and visited < max_nodes:
1541
+ var candidate = queue.pop_front()
1542
+ if not is_instance_valid(candidate):
1543
+ continue
1544
+ visited += 1
1545
+ if candidate is Control and _is_text_display_control(candidate):
1546
+ return candidate
1547
+ for child in candidate.get_children():
1548
+ if child is Node:
1549
+ queue.append(child)
1550
+ return null
1551
+
1552
+ func _summarize_control_structure(root: Node, max_depth: int, max_nodes: int) -> Array:
1553
+ var summary: Array = []
1554
+ if not is_instance_valid(root):
1555
+ return summary
1556
+ var queue: Array = [{
1557
+ "node": root,
1558
+ "depth": 0
1559
+ }]
1560
+ var visited := 0
1561
+ while queue.size() > 0 and visited < max_nodes:
1562
+ var entry = queue.pop_front()
1563
+ var node = entry["node"]
1564
+ var depth = entry["depth"]
1565
+ if not is_instance_valid(node) or depth > max_depth:
1566
+ continue
1567
+ var prefix := ""
1568
+ for i in range(depth):
1569
+ prefix += "-"
1570
+ summary.append("%s%s (%s)" % [prefix, node.name, node.get_class()])
1571
+ visited += 1
1572
+ if depth == max_depth:
1573
+ continue
1574
+ for child in node.get_children():
1575
+ if child is Node:
1576
+ queue.append({
1577
+ "node": child,
1578
+ "depth": depth + 1
1579
+ })
1580
+ return summary
1581
+
1582
+ func _collect_tree_lines(tree: Tree) -> Array:
1583
+ var lines: Array = []
1584
+ if not is_instance_valid(tree):
1585
+ return lines
1586
+ var root := tree.get_root()
1587
+ if not root:
1588
+ return lines
1589
+ var column_count: int = tree.columns
1590
+ if tree.has_method("is_hide_root") and not tree.is_hide_root():
1591
+ _collect_tree_item_lines(root, lines, 0, column_count)
1592
+ var child := root.get_first_child()
1593
+ while child:
1594
+ _collect_tree_item_lines(child, lines, 1, column_count)
1595
+ child = child.get_next()
1596
+ else:
1597
+ var item := root.get_first_child()
1598
+ while item:
1599
+ _collect_tree_item_lines(item, lines, 0, column_count)
1600
+ item = item.get_next()
1601
+ return lines
1602
+
1603
+ func _count_tree_items(tree: Tree) -> int:
1604
+ if not is_instance_valid(tree):
1605
+ return 0
1606
+ var root := tree.get_root()
1607
+ if not root:
1608
+ return 0
1609
+ var count := 0
1610
+ if tree.has_method("is_hide_root") and not tree.is_hide_root():
1611
+ count += 1
1612
+ var child := root.get_first_child()
1613
+ while child:
1614
+ count += 1
1615
+ child = child.get_next()
1616
+ else:
1617
+ var item := root.get_first_child()
1618
+ while item:
1619
+ count += 1
1620
+ item = item.get_next()
1621
+ return count
1622
+
1623
+ func _collect_tree_item_lines(item: TreeItem, lines: Array, depth: int, column_count: int) -> void:
1624
+ if not is_instance_valid(item):
1625
+ return
1626
+ var parts: Array = []
1627
+
1628
+ if item.has_meta("_is_warning"):
1629
+ parts.append("[warning]")
1630
+ elif item.has_meta("_is_error"):
1631
+ parts.append("[error]")
1632
+
1633
+ var main_text := item.get_text(0)
1634
+ if not main_text.is_empty():
1635
+ parts.append(main_text)
1636
+
1637
+ for col in range(1, column_count):
1638
+ var extra := item.get_text(col)
1639
+ if not extra.is_empty():
1640
+ parts.append(extra)
1641
+
1642
+ if parts.size() > 0:
1643
+ var prefix := _make_indent(depth)
1644
+ lines.append(prefix + " ".join(parts))
1645
+
1646
+ var child := item.get_first_child()
1647
+ while child:
1648
+ _collect_tree_item_lines(child, lines, depth + 1, column_count)
1649
+ child = child.get_next()
1650
+
1651
+ func get_capture_diagnostics() -> Dictionary:
1652
+ return {
1653
+ "source": _last_capture_source,
1654
+ "detail": _last_capture_detail,
1655
+ "timestamp": _last_capture_timestamp,
1656
+ "control_class": _last_control_class,
1657
+ "control_path": _last_control_path,
1658
+ "log_file_path": _last_log_file_path,
1659
+ "control_search": _last_control_search_summary
1660
+ }
1661
+
1662
+ func _make_indent(depth: int) -> String:
1663
+ if depth <= 0:
1664
+ return ""
1665
+ var spaces := depth * 2
1666
+ var builder := ""
1667
+ for i in range(spaces):
1668
+ builder += " "
1669
+ return builder