fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation

This commit is contained in:
2026-07-10 17:36:26 +08:00
parent 2b4c9d9dcb
commit b30709de3d
84 changed files with 5291 additions and 982 deletions
+138 -278
View File
@@ -14,20 +14,24 @@ var _debugger_plugin: McpDebuggerPlugin
var _game_log_buffer: McpGameLogBuffer
var _editor_log_buffer: McpEditorLogBuffer
var _debugger_errors_root: Node
var _debugger_search_root_cache: Node
var _surfaced_error_tracker
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null) -> void:
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_connection = connection
_debugger_plugin = debugger_plugin
_game_log_buffer = game_log_buffer
_editor_log_buffer = editor_log_buffer
_debugger_errors_root = debugger_errors_root
_surfaced_error_tracker = surfaced_error_tracker
if _surfaced_error_tracker == null:
_surfaced_error_tracker = McpSurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer, _debugger_errors_root)
func get_editor_state(_params: Dictionary) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
var game_status := _current_game_status()
var data := {
"godot_version": Engine.get_version_info().get("string", "unknown"),
"project_name": ProjectSettings.get_setting("application/config/name", ""),
@@ -38,6 +42,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
## false between Play→Stop cycles. Lets capture-source=game callers
## poll for a real ready signal instead of guessing with sleep().
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
"game_status": game_status,
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
}
## Half-installed addon tree from a failed self-update rollback. When
## non-empty, the agent / dock paint the operator-facing recovery copy
@@ -72,6 +79,7 @@ func get_logs(params: Dictionary) -> Dictionary:
var include_details: bool = bool(params.get("include_details", false))
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
if not source in VALID_LOG_SOURCES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
@@ -82,7 +90,7 @@ func get_logs(params: Dictionary) -> Dictionary:
"plugin":
return _get_plugin_logs(count, offset)
"game":
return _get_game_logs(count, offset, include_details)
return _get_game_logs(count, offset, include_details, since_run_id)
"editor":
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
"all":
@@ -90,6 +98,17 @@ func get_logs(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
func _current_game_status() -> Dictionary:
if _debugger_plugin == null:
return McpDebuggerPlugin.with_liveness_flags({
"status": "stopped",
"active": false,
"ready": false,
"helper_expected": true,
})
return _debugger_plugin.get_game_status()
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
var page: Array[Dictionary] = []
@@ -107,7 +126,10 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
}
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
var game_status := _current_game_status()
var helper_live := bool(game_status.get("helper_live", false))
var session_active := bool(game_status.get("session_active", false))
if _game_log_buffer == null:
return {
"data": {
@@ -117,23 +139,75 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
"returned_count": 0,
"offset": offset,
"run_id": "",
"is_running": false,
"current_run_id": "",
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": 0,
"stale_run_id": false,
}
}
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
return {
"data": {
"source": "game",
"lines": page,
"total_count": _game_log_buffer.total_count(),
"returned_count": page.size(),
"offset": offset,
"run_id": _game_log_buffer.run_id(),
"is_running": EditorInterface.is_playing_scene(),
"dropped_count": _game_log_buffer.dropped_count(),
}
var current_run_id := _game_log_buffer.run_id()
var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
var page := _entries_for_response(run_page.get("entries", []), include_details)
var data := {
"source": "game",
"lines": page,
"total_count": int(run_page.get("total_count", 0)),
"returned_count": page.size(),
"offset": offset,
"run_id": target_run_id,
"current_run_id": current_run_id,
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": _game_log_buffer.dropped_count(),
"stale_run_id": stale_run_id,
}
_merge_editor_errors_hint(data, game_status)
return {"data": data}
## #641: boot-time parse errors happen while autoload scripts compile — before
## the game helper's logger attaches via OS.add_logger — so they can NEVER
## appear in the game buffer. They surface only through the editor scope
## (Errors-tab rows + editor logger). Cross-reference them here so an
## empty/clean game log is not mistaken for a clean launch.
func _merge_editor_errors_hint(data: Dictionary, game_status: Dictionary) -> void:
if _debugger_plugin == null:
return
## A since_run_id read of a prior run must not carry the CURRENT run's
## editor errors — the hint interprets the run being read.
if bool(data.get("stale_run_id", false)):
return
## run_token == 0 means no tracked run ever started this session; the
## run-start cursor would be 0 and every retained editor error would be
## misattributed to "this run".
if int(game_status.get("run_token", 0)) <= 0:
return
## One-shot read — force the scan so rows that landed after the last
## gated scan (and before the deferred timers fire) make the FIRST
## logs_read(source='game') response, not just a later one.
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(
int(game_status.get("editor_log_cursor", 0)), true)
if str(errors_info.get("scope", "none")) != "run":
return
var errors: Array = errors_info.get("errors", [])
if errors.is_empty():
return
data["editor_errors_count"] = errors.size()
data["editor_errors_hint"] = (
"%d editor-side error%s from this run (first: %s) missing from the game log — boot-time parse/load errors occur before the game helper's logger attaches. Read logs_read(source='editor', include_details=true)."
% [errors.size(), "s" if errors.size() != 1 else "", _format_editor_error_summary(errors[0])]
)
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
@@ -211,21 +285,24 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
combined.append({"source": "plugin", "level": "info", "text": line})
for entry in _collect_editor_log_entries():
combined.append(entry)
var run_id := ""
var current_run_id := ""
var dropped := 0
if _game_log_buffer != null:
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
run_id = _game_log_buffer.run_id()
current_run_id = run_id
dropped = _game_log_buffer.dropped_count()
var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
for entry in run_page.get("entries", []):
combined.append(entry)
var stop := mini(combined.size(), offset + count)
var page: Array[Dictionary] = []
for i in range(mini(offset, combined.size()), stop):
page.append(combined[i])
page = _entries_for_response(page, include_details)
var run_id := ""
var dropped := 0
if _game_log_buffer != null:
run_id = _game_log_buffer.run_id()
dropped = _game_log_buffer.dropped_count()
if _editor_log_buffer != null:
dropped += _editor_log_buffer.dropped_count()
var game_status := _current_game_status()
return {
"data": {
"source": "all",
@@ -234,7 +311,11 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
"returned_count": page.size(),
"offset": offset,
"run_id": run_id,
"is_running": EditorInterface.is_playing_scene(),
"current_run_id": current_run_id,
"is_running": bool(game_status.get("session_active", false)),
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
"game_status": game_status,
"dropped_count": dropped,
}
}
@@ -256,208 +337,7 @@ func _entries_for_response(entries: Array[Dictionary], include_details: bool) ->
func _collect_editor_log_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
entries.append(entry)
for entry in _read_debugger_error_entries():
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in _locate_debugger_error_trees():
for entry in _entries_from_debugger_error_tree(tree):
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
if _debugger_plugin == null and _debugger_errors_root == null:
return trees
var root: Node = _debugger_errors_root
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func _debugger_search_root() -> Node:
## logs_read is a polling tool, so per-call discovery must not recurse the
## entire editor UI. EditorDebuggerNode is the bottom-panel container that
## owns every ScriptEditorDebugger session tab and lives for the editor's
## lifetime — find it once from the base control, then scan only its
## subtree on later calls. The error Trees themselves can't be cached:
## they are identified by their content, and an emptied tree is
## indistinguishable from any other Tree.
if is_instance_valid(_debugger_search_root_cache):
return _debugger_search_root_cache
_debugger_search_root_cache = null
var base := EditorInterface.get_base_control()
if base == null:
return null
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
if _debugger_search_root_cache == null:
return base
return _debugger_search_root_cache
static func _find_first_of_class(node: Node, klass: String) -> Node:
if node.get_class() == klass:
return node
for child in node.get_children():
var found := _find_first_of_class(child, klass)
if found != null:
return found
return null
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
if node is Tree and _tree_has_debugger_errors(node as Tree):
out.append(node as Tree)
for child in node.get_children():
if child is Node:
_collect_debugger_error_trees(child as Node, out)
static func _tree_has_debugger_errors(tree: Tree) -> bool:
var root := tree.get_root()
if root == null:
return false
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
return true
item = item.get_next()
return false
static func _entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return entries
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
entries.append(_entry_from_debugger_error_item(item))
item = item.get_next()
return entries
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
var title := item.get_text(1)
var loc := _location_from_metadata(item.get_metadata(0))
var function := _function_from_title(title)
return {
"source": "editor",
"level": "warn" if item.has_meta("_is_warning") else "error",
"text": title,
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
"details": _details_from_debugger_error_item(item, loc, function),
}
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
var children: Array[Dictionary] = []
var child := item.get_first_child()
while child != null:
var child_loc := _location_from_metadata(child.get_metadata(0))
children.append({
"label": child.get_text(0),
"text": child.get_text(1),
"path": str(child_loc.get("path", "")),
"line": int(child_loc.get("line", 0)),
})
child = child.get_next()
return {
"debugger_tab": "Errors",
"time": item.get_text(0),
"message": item.get_text(1),
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
"source": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"resolved": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"children": children,
"frames": _frames_from_error_children(children),
}
static func _is_debugger_error_item(item: TreeItem) -> bool:
return item.has_meta("_is_warning") or item.has_meta("_is_error")
## ScriptEditorDebugger lays out an error item's children flat, in order: an
## optional "<X Error>" row, one "<X Source>" row, then one row per stack
## frame. Only frame 0 carries the "<Stack Trace>" label (TTR-translated);
## later frames have an empty label. Every frame row carries [path, line]
## metadata, but so can the Error/Source rows, so metadata alone can't
## identify frames — the frame run has to be found first.
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
var start := -1
for i in children.size():
if str(children[i].label).contains("Stack Trace"):
start = i
break
if start < 0:
## Non-English editor locale: the "<Stack Trace>" label is translated.
## Frames past the first are the only rows with an empty label and a
## real location; back up one row to recover the labeled first frame
## (rows before the frame run always have a non-empty label).
for i in children.size():
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
start = maxi(i - 1, 0)
break
if start < 0:
return []
var frames: Array[Dictionary] = []
for i in range(start, children.size()):
if str(children[i].path).is_empty():
continue
frames.append({
"path": children[i].path,
"line": children[i].line,
"function": _function_from_frame_text(children[i].text),
})
return frames
static func _location_from_metadata(meta: Variant) -> Dictionary:
if meta is Array and meta.size() >= 2:
return {"path": str(meta[0]), "line": int(meta[1])}
return {"path": "", "line": 0}
static func _function_from_title(title: String) -> String:
var colon := title.find(": ")
if colon <= 0:
return ""
return title.substr(0, colon)
static func _function_from_frame_text(text: String) -> String:
var marker := text.find(" @ ")
if marker < 0:
return ""
var fn := text.substr(marker + 3).strip_edges()
if fn.ends_with("()"):
fn = fn.substr(0, fn.length() - 2)
return fn
return _surfaced_error_tracker.collect_editor_log_entries()
static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]:
@@ -468,23 +348,6 @@ static func _slice_entries(entries: Array[Dictionary], offset: int, count: int)
return page
static func _has_equivalent_log_entry(entries: Array[Dictionary], candidate: Dictionary) -> bool:
var key := _log_entry_key(candidate)
for entry in entries:
if _log_entry_key(entry) == key:
return true
return false
static func _log_entry_key(entry: Dictionary) -> String:
return "%s|%s|%s|%s" % [
str(entry.get("level", "")),
str(entry.get("text", "")),
str(entry.get("path", "")),
str(entry.get("line", 0)),
]
## Map of human-readable monitor names to Performance.Monitor enum values.
const MONITORS := {
"time/fps": Performance.TIME_FPS,
@@ -591,8 +454,30 @@ func take_screenshot(params: Dictionary) -> Dictionary:
return McpDispatcher.DEFERRED_RESPONSE
"cinematic":
return _take_cinematic_screenshot(max_resolution)
"viewport_2d":
viewport = EditorInterface.get_editor_viewport_2d()
if viewport == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No 2D viewport available")
var scene_root_2d := EditorInterface.get_edited_scene_root()
if scene_root_2d == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY,
"No scene open — open a scene first")
if not view_target.is_empty() or coverage or custom_elevation != null or custom_azimuth != null or custom_fov != null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"view_target, coverage, elevation, azimuth, and fov are not supported with source='viewport_2d'"
)
## Capture the 2D editor viewport directly; no view_target/coverage for 2D.
RenderingServer.force_draw(false)
var image_2d: Image = viewport.get_texture().get_image()
if image_2d == null or image_2d.is_empty():
return _empty_image_error(
"viewport_2d",
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
)
return _finalize_image(image_2d, "viewport_2d", max_resolution)
_:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'cinematic', or 'game'" % source)
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
## Handle view_target: temporarily reposition the editor's own camera to
## frame one or more target nodes, force a render, capture, then restore.
@@ -845,12 +730,14 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
return {}
var root_type := scene_root.get_class()
var hint: String
if scene_root is Node2D or scene_root is Control:
var is_2d_scene := scene_root is CanvasItem
if is_2d_scene:
hint = (
"The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. "
+ "Options: (a) open a 3D scene, "
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
+ "(c) use source=\"viewport_2d\" to capture the 2D editor viewport directly, "
+ "(d) call scene_get_hierarchy first to inspect what's available."
) % root_type
else:
hint = (
@@ -859,7 +746,10 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
) % root_type
return _make_viewport_not_3d_error(root_type, hint)
var err := _make_viewport_not_3d_error(root_type, hint)
if is_2d_scene:
err["error"]["data"]["suggestion"] = "use source='viewport_2d' for 2D scenes"
return err
## True if scene_root is itself a Node3D or owns any Node3D descendant.
@@ -1029,37 +919,7 @@ func clear_logs(params: Dictionary) -> Dictionary:
func _clear_debugger_error_trees() -> int:
var cleared := 0
for tree in _locate_debugger_error_trees():
cleared += _entries_from_debugger_error_tree(tree).size()
if not _press_debugger_clear_button(tree):
## No Clear button near this tree (synthetic roots in tests).
## A raw clear is acceptable there; the real panel always routes
## through the button below.
tree.clear()
return cleared
## Clear via ScriptEditorDebugger's own Clear button so the engine runs
## _clear_errors_list() — clearing the Tree directly leaves error_count/
## warning_count, the "Errors (N)" tab badge, the errors_cleared signal, and
## the toolbar button states out of sync with the emptied tree. The button is
## identified by its pressed-connection target, not its (translated) label.
static func _press_debugger_clear_button(tree: Tree) -> bool:
var parent := tree.get_parent()
if parent == null:
return false
var stack: Array[Node] = [parent]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is BaseButton:
for conn in node.get_signal_connection_list("pressed"):
if str(conn.get("callable", "")).contains("_clear_errors_list"):
node.emit_signal("pressed")
return true
for child in node.get_children():
stack.push_back(child)
return false
return _surfaced_error_tracker.clear_debugger_error_trees()
func reload_plugin(_params: Dictionary) -> Dictionary:
@@ -5,6 +5,32 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles file read/write operations and reimport within the Godot project.
## Bounds for the deferred scan wait. `write_file`/`reimport` register single
## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem`
## is the heavier, explicit "rebuild the class registry" path agents call after
## adding `class_name` scripts headlessly (no window focus to trigger it).
## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we
## always send a real reply before a DEFERRED_TIMEOUT is synthesised.
const _SCAN_START_GRACE_MSEC := 750
const _SCAN_SETTLE_MAX_MSEC := 28000
## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't
## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()`
## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing
## in that window would observe `false` and stack another scan() — the exact
## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is
## set before the first scan() and cleared when its settle coroutine finishes;
## concurrent requests coalesce onto the running scan instead of starting one.
## `static` so it's shared across handler instances; it resets on plugin reload
## (script re-parse), which self-heals any latch orphaned by a mid-await teardown.
static var _scan_in_flight := false
var _connection: McpConnection
func _init(connection: McpConnection = null) -> void:
_connection = connection
func read_file(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
@@ -110,3 +136,108 @@ func reimport(params: Dictionary) -> Dictionary:
"reason": "Reimport is a file system operation",
}
}
## Force a full EditorFileSystem scan and wait for it to settle. This is the
## headless equivalent of the editor regaining window focus: `update_file()`
## (used by write_file/reimport/script_create) registers a single file with the
## resource pipeline but does NOT rebuild the global `class_name` table, so a
## freshly-created `class_name MyThing extends Resource` stays invisible to
## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents
## driving the editor without focus call this once after a batch of script
## creates to make new types instantiable/referenceable. See issue #83.
func scan_filesystem(params: Dictionary) -> Dictionary:
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var request_id: String = params.get("_request_id", "")
# Async path: a scan can't be awaited on the calling frame without freezing
# the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and
# push the real reply from a static coroutine once the scan settles — by
# which point new class_names are registered.
if _connection != null and not request_id.is_empty():
_finish_scan_deferred(_connection, request_id, efs)
return McpDispatcher.DEFERRED_RESPONSE
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
# (no connection) can't await, so kick a single-flight scan and return
# immediately without the settle confirmation. Respect the latch so we don't
# stack onto a deferred scan; don't set it (there's no coroutine here to
# clear it — the brief is_scanning() window covers the rest).
var already := _scan_in_flight or efs.is_scanning()
if not already:
efs.scan()
return {
"data": {
"scan_completed": false,
"scan_settle": "not_waited",
"was_already_scanning": already,
"global_class_count": ProjectSettings.get_global_class_list().size(),
# Present in both paths for a consistent response shape; the sync
# path doesn't await, so it can't measure a delta.
"global_classes_registered_delta": 0,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
}
## `static` is load-bearing for the same reason as ScriptHandler's deferred
## finish: the coroutine must outlive the handler RefCounted, which can be freed
## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise
## everything; reference no instance state.
static func _finish_scan_deferred(
connection: McpConnection,
request_id: String,
efs: EditorFileSystem,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
var classes_before := ProjectSettings.get_global_class_list().size()
# Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(),
# which lags scan() by a frame or two — see the latch declaration). Only the
# request that sets the latch calls scan(); concurrent requests coalesce and
# just await the running scan. This is what actually prevents the stacked
# scan() SIGABRT (dsarno/godot#6), even within the start-grace window.
var was_already_scanning := _scan_in_flight or efs.is_scanning()
var we_started := not was_already_scanning
if we_started:
_scan_in_flight = true
efs.scan()
# Hand back a frame so _dispatch() registers this request as deferred before
# the coroutine can push a reply (mirrors _finish_create_script_deferred).
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC
var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC
var saw_scanning := efs.is_scanning()
while Time.get_ticks_msec() < deadline_ms:
if efs.is_scanning():
saw_scanning = true
elif saw_scanning or Time.get_ticks_msec() > start_grace_ms:
# Either the scan ran and finished, or it never flipped is_scanning()
# within the grace window (a no-op scan because nothing changed).
break
await tree.process_frame
# Clear the latch in all paths (no try/finally in GDScript): do it before the
# is_instance_valid early-return so a freed connection can't orphan it.
if we_started:
_scan_in_flight = false
if not is_instance_valid(connection):
return
var completed := not efs.is_scanning()
var classes_after := ProjectSettings.get_global_class_list().size()
connection.send_deferred_response(request_id, {
"data": {
"scan_completed": completed,
"scan_settle": "settled" if completed else "timeout",
"was_already_scanning": was_already_scanning,
"global_class_count": classes_after,
"global_classes_registered_delta": classes_after - classes_before,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
})
+196 -14
View File
@@ -18,11 +18,13 @@ func list_actions(params: Dictionary) -> Dictionary:
## See #213.
var user_authored := _read_user_authored_actions()
var actions: Array[Dictionary] = []
var seen := {}
for action_name in InputMap.get_actions():
var name_str := str(action_name)
var is_user_action := user_authored.has(name_str)
if not include_builtin and not is_user_action:
continue
seen[name_str] = true
var events: Array[Dictionary] = []
for event in InputMap.action_get_events(action_name):
events.append(_serialize_event(event))
@@ -31,6 +33,25 @@ func list_actions(params: Dictionary) -> Dictionary:
"events": events,
"event_count": events.size(),
"is_builtin": not is_user_action,
"loaded_in_input_map": true,
})
for action_name in user_authored.keys():
var name_str := str(action_name)
if seen.has(name_str):
continue
var setting: Dictionary = user_authored.get(name_str, {})
var events: Array[Dictionary] = []
for event in setting.get("events", []):
if event is InputEvent:
events.append(_serialize_event(event))
else:
events.append({"type": type_string(typeof(event)), "string": str(event)})
actions.append({
"name": name_str,
"events": events,
"event_count": events.size(),
"is_builtin": false,
"loaded_in_input_map": false,
})
return {"data": {"actions": actions, "count": actions.size()}}
@@ -43,7 +64,8 @@ func _read_user_authored_actions() -> Dictionary:
return {}
var result: Dictionary = {}
for key in cfg.get_section_keys("input"):
result[key] = true
var value = cfg.get_value("input", key, {})
result[key] = value if value is Dictionary else {}
return result
@@ -54,9 +76,9 @@ func add_action(params: Dictionary) -> Dictionary:
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
if InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action)
@@ -85,28 +107,53 @@ func add_action(params: Dictionary) -> Dictionary:
}
func ensure_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var result := _ensure_action_state(action, deadzone)
if result.has("error"):
return result
return {"data": result}
func remove_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if not InputMap.has_action(action):
var key := "input/%s" % action
var was_loaded := InputMap.has_action(action)
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
## An action can live in the editor process's InputMap, in project.godot,
## or both. Actions persisted by a previous editor session exist only on
## disk (`loaded_in_input_map: false` in list_actions) — those must still
## be removable. #632
if not was_loaded and old_setting == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
var key := "input/%s" % action
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
InputMap.erase_action(action)
if was_loaded:
InputMap.erase_action(action)
if old_setting != null:
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
if was_loaded:
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
ProjectSettings.set_setting(key, old_setting)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err])
@@ -115,6 +162,7 @@ func remove_action(params: Dictionary) -> Dictionary:
"data": {
"action": action,
"removed": true,
"was_loaded": was_loaded,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
@@ -157,6 +205,112 @@ func bind_event(params: Dictionary) -> Dictionary:
}
func ensure_binding(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var event_type: String = params.get("event_type", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if event_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var event_or_error = _create_event(event_type, params)
if event_or_error is Dictionary:
return event_or_error
var event: InputEvent = event_or_error
var ensured := _ensure_action_state(action, deadzone)
if ensured.has("error"):
return ensured
for existing in InputMap.action_get_events(action):
if _events_match(existing, event):
return {
"data": {
"action": action,
"event": _serialize_event(existing),
"already_bound": true,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input binding already exists",
}
}
InputMap.action_add_event(action, event)
var err := _save_action_events(action)
if err != OK:
InputMap.action_erase_event(action, event)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"event": _serialize_event(event),
"already_bound": false,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input bindings are saved to project.godot",
}
}
func _validate_deadzone(deadzone: float) -> Dictionary:
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
return {}
func _ensure_action_state(action: String, deadzone: float) -> Dictionary:
var key := "input/%s" % action
var user_authored := _read_user_authored_actions()
var existed_in_input_map := InputMap.has_action(action)
var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key)
var old_setting = user_authored.get(action, null) if user_authored.has(action) else null
if old_setting == null and ProjectSettings.has_setting(key):
old_setting = ProjectSettings.get_setting(key)
if not existed_in_input_map:
var dz := deadzone
if old_setting is Dictionary:
dz = float(old_setting.get("deadzone", deadzone))
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
if not existed_in_project:
var err := _save_action_events(action)
if err != OK:
if not existed_in_input_map:
InputMap.erase_action(action)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err])
var stored_deadzone := deadzone
if ProjectSettings.has_setting(key):
var stored = ProjectSettings.get_setting(key)
if stored is Dictionary:
stored_deadzone = float(stored.get("deadzone", deadzone))
return {
"action": action,
"deadzone": stored_deadzone,
"created": not existed_in_input_map and not existed_in_project,
"already_exists": existed_in_input_map or existed_in_project,
"loaded_in_input_map": true,
"persisted": true,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
## Returns an InputEvent on success, or a Dictionary error on failure.
## Caller must check ``result is Dictionary`` before treating it as an event.
func _create_event(event_type: String, params: Dictionary):
@@ -257,6 +411,32 @@ func _serialize_event(event: InputEvent) -> Dictionary:
return {"type": event.get_class(), "string": str(event)}
func _events_match(a: InputEvent, b: InputEvent) -> bool:
if a is InputEventKey and b is InputEventKey:
return _key_events_match(a as InputEventKey, b as InputEventKey)
return _serialize_event(a) == _serialize_event(b)
func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool:
if a.ctrl_pressed != b.ctrl_pressed:
return false
if a.alt_pressed != b.alt_pressed:
return false
if a.shift_pressed != b.shift_pressed:
return false
if a.meta_pressed != b.meta_pressed:
return false
var a_codes := [a.keycode, a.physical_keycode]
var b_codes := [b.keycode, b.physical_keycode]
for a_code in a_codes:
if int(a_code) == KEY_NONE:
continue
for b_code in b_codes:
if int(b_code) != KEY_NONE and int(a_code) == int(b_code):
return true
return false
func _save_action_events(action: String) -> int:
var events: Array = []
for event in InputMap.action_get_events(action):
@@ -267,6 +447,8 @@ func _save_action_events(action: String) -> int:
var deadzone: float = 0.5
if old_setting is Dictionary:
deadzone = old_setting.get("deadzone", 0.5)
elif InputMap.has_action(action):
deadzone = InputMap.action_get_deadzone(action)
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": events,
+137 -12
View File
@@ -239,16 +239,10 @@ func set_property(params: Dictionary) -> Dictionary:
# properties. Mirrors resource_create's inline-assign path but
# avoids a separate tool call for the common case.
var type_str: String = value.get("__class__", "")
var class_err := ResourceHandler._validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null or not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource" % type_str
)
var res: Resource = instance
var made := ResourceHandler._instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
var remaining: Dictionary = (value as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -528,6 +522,7 @@ func _set_owner_recursive(node: Node, owner: Node) -> void:
## is optional — the coercer defaults it to 1.0 when absent.
const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
@@ -556,6 +551,8 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedVector2Array
TYPE_PACKED_VECTOR3_ARRAY:
ok = value is PackedVector3Array
TYPE_PACKED_VECTOR4_ARRAY:
ok = value is PackedVector4Array
TYPE_PACKED_COLOR_ARRAY:
ok = value is PackedColorArray
TYPE_PACKED_INT32_ARRAY:
@@ -568,8 +565,31 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedFloat64Array
TYPE_PACKED_STRING_ARRAY:
ok = value is PackedStringArray
TYPE_VECTOR2I: ok = value is Vector2i
TYPE_VECTOR3I: ok = value is Vector3i
TYPE_VECTOR4: ok = value is Vector4
TYPE_VECTOR4I: ok = value is Vector4i
TYPE_QUATERNION: ok = value is Quaternion
TYPE_RECT2: ok = value is Rect2
TYPE_RECT2I: ok = value is Rect2i
TYPE_AABB: ok = value is AABB
TYPE_PLANE: ok = value is Plane
TYPE_BASIS: ok = value is Basis
TYPE_TRANSFORM2D: ok = value is Transform2D
TYPE_TRANSFORM3D: ok = value is Transform3D
TYPE_PROJECTION: ok = value is Projection
_:
return null
# null / untyped-TYPE_NIL / already-correct-type are handled by
# Godot's setter; anything else would silently no-op, so error.
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
return null
var unsupported := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
type_string(typeof(value)), type_string(target_type),
],
)
return ErrorCodes.prefix_message(unsupported, prefix)
if ok:
return null
var dict_err := _check_dict_coerce_failed(value, target_type)
@@ -597,6 +617,8 @@ static func _shape_hint(target_type: int) -> String:
return "[{\"x\":0,\"y\":0}, ...]"
TYPE_PACKED_VECTOR3_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
TYPE_PACKED_VECTOR4_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
TYPE_PACKED_COLOR_ARRAY:
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
@@ -605,6 +627,24 @@ static func _shape_hint(target_type: int) -> String:
return "[float, ...]"
TYPE_PACKED_STRING_ARRAY:
return "[\"...\", ...]"
TYPE_VECTOR2I:
return "{\"x\":0,\"y\":0}"
TYPE_VECTOR3I:
return "{\"x\":0,\"y\":0,\"z\":0}"
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
return "{\"position\":{...},\"size\":{...}}"
TYPE_PLANE:
return "{\"normal\":{...},\"d\":0}"
TYPE_BASIS:
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
TYPE_TRANSFORM2D:
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
TYPE_TRANSFORM3D:
return "{\"basis\":{...},\"origin\":{...}}"
TYPE_PROJECTION:
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
var keys: Array[String] = []
match target_type:
TYPE_VECTOR2: keys = VECTOR2_KEYS
@@ -666,7 +706,15 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
if value is Dictionary and value.has_all(COLOR_KEYS):
return Color(value["r"], value["g"], value["b"], value.get("a", 1.0))
if value is String:
return Color(value)
# Color(String) silently returns black for an unparseable string
# (e.g. "Color(1,1,1,1)" or a typo). Validate with the two-sentinel
# from_string idiom (see theme_handler/material_values); on failure
# fall through so the value stays a String and _check_coerced flags
# it as an error instead of writing black.
var col_a := Color.from_string(value, Color(0, 0, 0, 0))
var col_b := Color.from_string(value, Color(1, 1, 1, 1))
if col_a == col_b:
return col_a
TYPE_BOOL:
if value is float or value is int:
return bool(value)
@@ -717,6 +765,17 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_PACKED_VECTOR4_ARRAY:
if value is Array:
var out := PackedVector4Array()
for item in value:
if item is Vector4:
out.append(item)
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
else:
return value
return out
TYPE_PACKED_COLOR_ARRAY:
if value is Array:
var out := PackedColorArray()
@@ -757,6 +816,72 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_VECTOR2I:
if value is Dictionary and value.has_all(VECTOR2_KEYS):
return Vector2i(int(value["x"]), int(value["y"]))
TYPE_VECTOR3I:
if value is Dictionary and value.has_all(VECTOR3_KEYS):
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
TYPE_VECTOR4:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4(value["x"], value["y"], value["z"], value["w"])
TYPE_VECTOR4I:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
TYPE_QUATERNION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Quaternion(value["x"], value["y"], value["z"], value["w"])
TYPE_RECT2:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
if p is Vector2 and s is Vector2:
return Rect2(p, s)
TYPE_RECT2I:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
if p is Vector2i and s is Vector2i:
return Rect2i(p, s)
TYPE_AABB:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
if p is Vector3 and s is Vector3:
return AABB(p, s)
TYPE_PLANE:
if value is Dictionary and value.has("normal") and value.has("d"):
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
if n is Vector3:
return Plane(n, float(value["d"]))
TYPE_BASIS:
if value is Dictionary and value.has_all(["x", "y", "z"]):
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
if bx is Vector3 and by is Vector3 and bz is Vector3:
return Basis(bx, by, bz)
TYPE_TRANSFORM2D:
if value is Dictionary and value.has_all(["x", "y", "origin"]):
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
return Transform2D(tx, ty, to_)
TYPE_TRANSFORM3D:
if value is Dictionary and value.has("basis") and value.has("origin"):
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
if b is Basis and o is Vector3:
return Transform3D(b, o)
TYPE_PROJECTION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
return Projection(px, py, pz, pw)
# PackedByteArray intentionally unhandled — needs design decision
# (base64 string vs. raw int list); JSON has no native byte type.
return value
+229 -20
View File
@@ -6,14 +6,17 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles project settings and filesystem search commands.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const RUN_READY_WAIT_SEC := 3.0
var _connection: McpConnection
var _debugger_plugin
var _editor_log_buffer
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
_editor_log_buffer = editor_log_buffer
func get_project_setting(params: Dictionary) -> Dictionary:
@@ -81,16 +84,15 @@ func run_project(params: Dictionary) -> Dictionary:
# stop-not-running case in telemetry). Surface state via was_already_running
# so a caller wanting a *different* scene can detect and stop+restart.
if EditorInterface.is_playing_scene():
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": true,
"undoable": false,
"reason": "Project was already running; no action taken",
}
}
return _run_project_current_liveness_response(
_run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
true,
"Project was already running; no action taken"
)
)
var validation_error: Variant = null
if mode == "custom":
@@ -125,7 +127,7 @@ func run_project(params: Dictionary) -> Dictionary:
restore_setting = true
if _debugger_plugin != null:
_debugger_plugin.begin_game_run()
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
match mode:
"main":
@@ -142,18 +144,225 @@ func run_project(params: Dictionary) -> Dictionary:
if _connection:
_connection.pause_processing = false
var base_data := _run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
false,
"Play/stop is a runtime action"
)
var request_id: String = params.get("_request_id", "")
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
_finish_run_project_deferred(request_id, base_data)
return McpDispatcher.DEFERRED_RESPONSE
return _run_project_current_liveness_response(base_data)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func _game_helper_autoload_expected() -> bool:
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
func _run_project_base_data(
mode: String,
scene: String,
autosave: bool,
was_already_running: bool,
reason: String
) -> Dictionary:
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": false,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
"mode": mode,
"scene": scene,
"autosave": autosave,
"was_already_running": was_already_running,
"undoable": false,
"reason": reason,
}
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
if _debugger_plugin == null:
return {"data": base_data}
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
## One-shot read — force a Debugger-tab scan so boot errors that landed
## after the last gated scan are in this response (#641).
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
var tree := _connection.get_tree()
while true:
await tree.process_frame
if not is_instance_valid(_connection):
return
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
if (
not EditorInterface.is_playing_scene()
and int(pre_status.get("elapsed_msec", 0)) > 100
and str(pre_status.get("status", "stopped")) == "launching"
):
_debugger_plugin.end_game_run()
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var decision := _run_project_liveness_decision(status, errors_info)
if not bool(decision.get("resolve", false)):
continue
## #641: the loop above polls with gated (cheap) scans; boot parse
## errors can land in the Errors tab in the same frames the run goes
## live. Re-gather once with a forced scan before replying so the
## response reports them instead of leaving them to a later
## logs_read. Rebuilding the decision with strictly-more errors can
## only keep it resolved (errors never un-resolve a decision).
errors_info = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
decision = _run_project_liveness_decision(status, errors_info)
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
return
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
var data := base_data.duplicate(true)
var game_status: Dictionary = decision.get("game_status", {})
data["game_status"] = game_status
data["helper_live"] = bool(game_status.get("helper_live", false))
data["session_active"] = bool(game_status.get("session_active", false))
if bool(data.get("was_already_running", false)):
data["reason"] = _run_project_already_running_message(decision)
else:
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
data["recent_errors"] = decision.get("recent_errors", [])
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
return {"data": data}
func _run_project_already_running_message(decision: Dictionary) -> String:
var state := str(decision.get("liveness_status", "unknown"))
match state:
"live":
var live_errors: Array = decision.get("recent_errors", [])
if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return (
"Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
% [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
)
return "Project was already running; the Godot AI game helper is live."
"not_live":
var errors: Array = decision.get("recent_errors", [])
var scope := str(decision.get("recent_errors_scope", "none"))
if not errors.is_empty() and scope == "run":
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
if not errors.is_empty():
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
"break":
var break_errors: Array = decision.get("recent_errors", [])
if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
if not break_errors.is_empty():
return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
"no_helper":
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
"launching":
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
"stopped":
return "Project was already marked playing by the editor, but no active game liveness run exists."
_:
return "Project was already running; current liveness status is %s." % state
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
var state := str(status.get("status", "stopped"))
var recent_errors: Array = errors_info.get("errors", [])
var errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
var elapsed_msec := int(status.get("elapsed_msec", 0))
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
var decision := {
"resolve": false,
"game_status": enriched_status,
"liveness_status": state,
"recent_errors": recent_errors,
"recent_errors_scope": errors_scope,
"recent_errors_may_predate_run": errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
"message": "",
}
if state == "live":
decision["resolve"] = true
if correlated_error:
## #641: "live" only means the helper autoload registered — scripts
## can still have failed to parse or load during boot (a broken
## node script does not stop the game from running). Surface those
## errors in the success message so agents don't read a clean
## launch into a run that silently lost scripts.
decision["message"] = (
"Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
% [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
)
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
else:
decision["message"] = "Game launched and the Godot AI game helper is live."
elif state == "break":
## #645: the game process is parked in a remote-debugger break. A
## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
## no Errors-tab row, no Logger entry, and no game-log line — the
## synthesized break record is the only evidence, and it lands a
## moment after the break signal (stack frames arrive async). Wait for
## it (correlated_error) before resolving; the ready window is the
## fallback if synthesis never lands.
var break_info: Dictionary = status.get("break", {})
var break_reason := str(break_info.get("reason", ""))
if bool(break_info.get("pre_live", true)):
decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
var summary := break_reason
if correlated_error:
summary = _format_editor_error_summary(recent_errors[0])
if summary.is_empty():
summary = "script parse/load error (reason not captured)"
decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
else:
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
decision["resolve"] = true
decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
elif correlated_error:
decision["resolve"] = true
decision["liveness_status"] = "not_live"
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
elif state == "not_live":
decision["resolve"] = true
if not recent_errors.is_empty():
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
elif state == "no_helper":
decision["resolve"] = true
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
elif state == "stopped":
decision["resolve"] = true
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
elif state == "launching" and elapsed_msec >= ready_wait_msec:
decision["resolve"] = true
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
return decision
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func stop_project(params: Dictionary) -> Dictionary:
# Idempotent: a project that's already stopped satisfies the caller's intent.
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
+177 -25
View File
@@ -178,19 +178,10 @@ func create_resource(params: Dictionary) -> Dictionary:
return home_err
var has_file_target := not resource_path.is_empty()
var class_err := _validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
if not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
)
var res: Resource = instance
var made := _instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
if not properties.is_empty():
var apply_err := _apply_resource_properties(res, properties)
@@ -226,6 +217,75 @@ static func _validate_resource_class(type_str: String) -> Variant:
return null
## Build the "Unknown resource type" error with a steer toward op="scan". A type
## that reaches here is neither an engine built-in (ClassDB) nor a registered
## project class (the global script-class registry). In an agent-driven workflow
## the most common cause is a `class_name` script just made via script_create
## that isn't registered yet — the global class table only rebuilds on a
## filesystem scan (normally an editor-focus event). Point the caller at the one
## cheap call that fixes that, so it doesn't fall back to a full plugin reload.
## See #614 for the headless scan op.
static func _unknown_resource_type_error(type_str: String) -> Dictionary:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"Unknown resource type: %s — not an engine built-in or a registered project class. "
+ "If you just created it with script_create, the global class table is stale until a "
+ "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling."
) % type_str
)
## Resolve a resource type name to a fresh instance. Handles engine built-ins
## (ClassDB) and project `class_name` Resources (the global script-class
## registry). Returns a Resource on success, or an error dict on failure.
static func _instantiate_resource(type_str: String) -> Variant:
if ClassDB.class_exists(type_str):
var class_err: Variant = _validate_resource_class(type_str)
if class_err != null:
return class_err
var built_in := ClassDB.instantiate(type_str)
if built_in == null or not (built_in is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return built_in
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == type_str:
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
# Reject non-Resource script classes BEFORE constructing them:
# scr.new() runs _init(), and an @tool class_name extending a
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
# an orphan instance this path never frees. get_instance_base_type()
# resolves to the native base, so multi-level custom Resource
# hierarchies (B extends A extends Resource) still pass.
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
if not scr.can_instantiate():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
# Reject scripts whose _init() requires arguments BEFORE scr.new():
# scr.new() passes no args, so a required-arg _init raises and aborts
# this handler mid-call, null-cascading into a generic "malformed
# result" error instead of a clean rejection. get_script_method_list()
# reports the effective (incl. inherited) _init; required args =
# args - default_args. Statically detectable only — a _init that runs
# but throws still falls through to scr.new() and the dispatcher catch.
for method in scr.get_script_method_list():
if method.get("name", "") == "_init":
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
if required_args > 0:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
break
var made: Variant = scr.new()
if made == null or not (made is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return made
return _unknown_resource_type_error(type_str)
## Apply a dict of property values to a freshly-instantiated Resource,
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
## Returns null on success or an error dict on failure.
@@ -240,9 +300,17 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
valid.append(prop.name)
valid.sort()
# Name the script's class_name (e.g. MyTestResource) rather than the
# native base (Resource) so the hint names the type the agent created,
# and point at the real MCP verb — resource_manage(op="get_info") now
# answers for project class_name Resources too.
var type_label := res.get_class()
var res_script: Variant = res.get_script()
if res_script is Script and not String(res_script.get_global_name()).is_empty():
type_label = String(res_script.get_global_name())
var err := ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
)
err["error"]["data"] = {"valid_properties": valid}
return err
@@ -270,16 +338,15 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
# resource_create/environment_create callers can populate
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
var sub_type: String = v.get("__class__", "")
var class_err := _validate_resource_class(sub_type)
if class_err != null:
return class_err
var sub_instance := ClassDB.instantiate(sub_type)
if sub_instance == null or not (sub_instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
)
var sub_res: Resource = sub_instance
# Resolve via the shared helper so the nested shortcut accepts both
# engine built-ins (ClassDB) and project `class_name` Resources,
# exactly like the top-level resource_create path.
var sub_made := _instantiate_resource(sub_type)
if sub_made is Dictionary:
# Preserve the property-slot context the inline path used to add.
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
return sub_made
var sub_res: Resource = sub_made
var remaining: Dictionary = (v as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -361,7 +428,13 @@ func get_resource_info(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
if not ClassDB.class_exists(type_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
# Project class_name Resources aren't in ClassDB; resolve them through the
# global script-class registry so get_info answers for the same custom
# types resource_create can make. Read-only — never instantiates.
var custom_info: Variant = _custom_resource_info(type_str)
if custom_info != null:
return custom_info
return _unknown_resource_type_error(type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
@@ -396,3 +469,82 @@ func get_resource_info(params: Dictionary) -> Dictionary:
data["concrete_subclasses"] = class_info.concrete_inheritors
return {"data": data}
## Resolve a loaded global-class script to its native base type, or an error if
## the script failed to load (not a Script) or to compile (empty base type).
## Shared by the create and get_info custom-Resource paths so both report a
## compile failure rather than a misleading "is not a Resource type (extends )".
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
if not (scr is Script):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
var base_type: StringName = scr.get_instance_base_type()
if String(base_type).is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
return base_type
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
## dict, an error dict (for a class_name whose native base is not a Resource), or
## null if `type_str` is not a registered global class. Read-only: resolves
## properties from the script + its native base WITHOUT instantiating (no _init()).
static func _custom_resource_info(type_str: String) -> Variant:
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") != type_str:
continue
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
var can_instantiate: bool = scr.can_instantiate()
# Inherited (native) properties come from the engine base via ClassDB...
var class_info := ClassIntrospection.build(String(base_type), {
"sections": ["properties"],
"include_inherited": true,
"limit": 0,
})
var props: Array = []
for native_prop in class_info.properties:
props.append(native_prop)
# ...and the script's own (and inherited script) exported properties come
# from the Script itself, so we never construct the resource. A real
# default isn't available without instantiating, so script props carry an
# explicit null — keeping one uniform key set across the array (native
# props carry their real default).
for raw_prop in scr.get_script_property_list():
var prop: Dictionary = raw_prop
var usage := int(prop.get("usage", 0))
if not (usage & PROPERTY_USAGE_EDITOR):
continue
props.append({
"name": str(prop.get("name", "")),
"type": type_string(int(prop.get("type", TYPE_NIL))),
"class_name": str(prop.get("class_name", "")),
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
"hint_string": str(prop.get("hint_string", "")),
"usage": usage,
"default": null,
})
props.sort_custom(func(a, b): return a.name < b.name)
# parent_class is the immediate script parent when there is one (so a
# multi-level chain B -> A -> Resource reports A), else the native base.
var parent_name := String(base_type)
var base_script: Variant = scr.get_base_script()
if base_script is Script and not String(base_script.get_global_name()).is_empty():
parent_name = String(base_script.get_global_name())
return {"data": {
"type": type_str,
"parent_class": parent_name,
"can_instantiate": can_instantiate,
# is_abstract reflects real abstractness (the @abstract annotation),
# NOT editor-instantiability — a non-@tool concrete Resource has
# can_instantiate()==false in-editor but is not abstract.
"is_abstract": scr.is_abstract(),
"properties": props,
"property_count": props.size(),
}}
return null
+91 -8
View File
@@ -143,9 +143,17 @@ func create_scene(params: Dictionary) -> Dictionary:
}
## How long open_scene waits for the editor to actually switch to the
## requested scene before replying switched=false. Tab switches normally land
## within a few frames; keep this under the dispatcher's 4500 ms deferred
## default so the coroutine always answers before DEFERRED_TIMEOUT fires.
const _OPEN_SETTLE_MAX_MSEC := 3000
## Open an existing scene by file path.
func open_scene(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var force_reload: bool = params.get("force_reload", false)
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
@@ -156,16 +164,91 @@ func open_scene(params: Dictionary) -> Dictionary:
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
EditorInterface.open_scene_from_path(path)
return {
"data": {
"path": path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
var scene_root := EditorInterface.get_edited_scene_root()
var current_path := scene_root.scene_file_path if scene_root else ""
## Instance id of the root at call time. A completed open OR reload always
## replaces the edited-scene root with a NEW instance, so this is the
## reliable completion signal — unlike scene_file_path, which is unchanged
## across a force_reload of the already-open scene (#633 review).
var prev_root_id := scene_root.get_instance_id() if scene_root else 0
var payload := {
"path": path,
"force_reload": force_reload,
"reloaded_from_disk": false,
"previous_scene_path": current_path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
if current_path == path and not force_reload:
## Already the edited scene — nothing switches, reply immediately.
payload["switched"] = true
payload["settle"] = "already_current"
return {"data": payload}
if force_reload and current_path == path:
EditorInterface.reload_scene_from_path(path)
payload["reloaded_from_disk"] = true
else:
EditorInterface.open_scene_from_path(path)
## The tab switch completes asynchronously; replying now lets an immediate
## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued
## right after open_scene saved the old scene). Defer the reply until the
## edited scene actually is `path` AND its root is a fresh instance, so
## success means "the editor is now editing the (re)loaded scene".
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
_finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload)
return McpDispatcher.DEFERRED_RESPONSE
## Synchronous fallback (batch_execute and unit-test contexts can't await):
## preserve the old reply-immediately behavior, flagged as not waited on.
payload["switched"] = false
payload["settle"] = "not_waited"
return {"data": payload}
## `static` is load-bearing (same reason as FilesystemHandler's deferred scan
## finish): the coroutine must outlive this RefCounted handler, which can be
## freed mid-await by an editor_reload_plugin. Parameterise everything;
## reference no instance state.
static func _finish_open_scene_deferred(
connection: McpConnection,
request_id: String,
path: String,
prev_root_id: int,
payload: Dictionary,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
# Hand back a frame so _dispatch() registers this request as deferred
# before the coroutine can push a reply.
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC
while Time.get_ticks_msec() < deadline_ms:
var root := EditorInterface.get_edited_scene_root()
# Require BOTH the target path AND a fresh root instance: a
# force_reload keeps scene_file_path == path across the reload, so the
# instance swap is what proves the (re)load actually completed rather
# than the coroutine settling on the stale pre-reload root.
if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id:
if not is_instance_valid(connection):
return
payload["switched"] = true
payload["settle"] = "settled"
connection.send_deferred_response(request_id, {"data": payload})
return
await tree.process_frame
if not is_instance_valid(connection):
return
payload["switched"] = false
payload["settle"] = "timeout"
connection.send_deferred_response(request_id, {"data": payload})
## Save the currently edited scene.
## Pauses WebSocket processing during save to prevent re-entrant _process()
+74 -10
View File
@@ -3,7 +3,7 @@ extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
@@ -63,6 +63,33 @@ func create_script(params: Dictionary) -> Dictionary:
}
_attach_gdscript_diagnostics(data, path, content)
# A freshly-declared `class_name` is NOT in the global class table until a
# filesystem scan runs — update_file() below registers the file with the
# resource pipeline but not the class registry (see the scan() comment).
# Surface that precisely (only when the class isn't already registered) so a
# headless caller knows to follow up with filesystem_manage(op="scan")
# instead of hitting a confusing "Unknown type" / "Unknown resource type" on
# the very next call. We don't scan here — a scan() per create is the exact
# SIGABRT race documented below; the explicit op is single-flight.
# Skip the hint when the script failed to parse: a scan won't register a
# class from a broken script, so pointing at op="scan" would steer the caller
# away from the real fix (the parse error already attached above).
var declared_class := _extract_class_name(content)
if (
not declared_class.is_empty()
and not _script_has_error_diagnostics(data)
and not _class_name_registered(declared_class)
):
data["class_name"] = declared_class
data["class_registration"] = "scan_required"
data["class_registration_hint"] = (
"New class_name '%s' isn't in the global class table yet. " % declared_class
+ "Call filesystem_manage(op=\"scan\") if it won't resolve on the next "
+ "call (e.g. resource_manage op=\"create\", or used as a type in another "
+ "script). The editor also registers it on its next filesystem scan or "
+ "when its window regains focus."
)
# Register just this file with the editor instead of a full recursive
# scan(). A scan() per write stacks `update_scripts_classes` /
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
@@ -142,6 +169,48 @@ static func _finish_create_script_deferred(
connection.send_deferred_response(request_id, {"data": payload})
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
## (no full parse) for create_script's "scan_required" hint. Stops at the first
## space/tab or comma so all three valid forms yield just the name:
## `class_name Foo`, `class_name Foo extends Bar`, and the icon form
## `class_name Foo, "res://icon.svg"`.
static func _extract_class_name(content: String) -> String:
for raw_line in content.split("\n"):
var line := raw_line.strip_edges()
if line.begins_with("class_name "):
var rest := line.substr(11).strip_edges()
var cut := rest.length()
for i in rest.length():
var ch := rest[i]
if ch == " " or ch == "\t" or ch == ",":
cut = i
break
return rest.substr(0, cut)
return ""
## True if create_script's diagnostics captured a parse error for this script.
## Used to suppress the "scan_required" hint when the class can't register
## anyway — see create_script.
static func _script_has_error_diagnostics(data: Dictionary) -> bool:
for diag in data.get("diagnostics", []):
if diag is Dictionary and diag.get("level", "") == "error":
return true
return false
## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an
## already-registered project global class. A brand-new class_name returns false
## until a filesystem scan registers it.
static func _class_name_registered(cn: String) -> bool:
if ClassDB.class_exists(cn):
return true
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == cn:
return true
return false
func read_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
@@ -204,21 +273,16 @@ static func _validate_gdscript_source(content: String) -> Dictionary:
}
static func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger") and OS.has_method("remove_logger")):
return _empty_diagnostics_capture()
var logger_script := LoggerLoader.build(LoggerLoader.VALIDATION_LOGGER_PATH)
if logger_script == null:
return _empty_diagnostics_capture()
func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
var buffer := McpEditorLogBuffer.new()
var logger = logger_script.new(buffer)
var logger := ValidationLogger.new(buffer)
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
OS.call("add_logger", logger)
OS.add_logger(logger)
# ResourceLoader.load() reports parse failure instead of throwing, and
# a failed GDScript parse does not execute user code; remove immediately
# after the synchronous load to keep the private capture window tiny.
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
OS.call("remove_logger", logger)
OS.remove_logger(logger)
return {}
)
return capture
+12 -2
View File
@@ -152,7 +152,7 @@ func connect_signal(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
_undo_redo.add_do_method(source, "connect", signal_name, callable)
_undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
_undo_redo.commit_action()
@@ -174,9 +174,19 @@ func disconnect_signal(params: Dictionary) -> Dictionary:
if not source.is_connected(signal_name, callable):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
# Capture the connection's current flags so undo restores it exactly as it
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
# silently promote a runtime-only connection into one that serializes on the
# next save. (The connection still exists at this point — checked above.)
var reconnect_flags := 0
for conn in source.get_signal_connection_list(signal_name):
if conn.get("callable", Callable()) == callable:
reconnect_flags = int(conn.get("flags", 0))
break
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
_undo_redo.commit_action()
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
+28 -1
View File
@@ -30,7 +30,11 @@ func run_tests(params: Dictionary) -> Dictionary:
discovery.errors.size(),
", ".join(discovery.errors),
]
return {"data": {"error": msg, "total": 0, "load_errors": discovery.errors}}
var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors}
## Keep the edited_scene annotation on the no-suites error payload too,
## so the response contract is consistent across every return path.
_annotate_edited_scene(no_suites)
return {"data": no_suites}
var ctx := {
"undo_redo": _undo_redo,
@@ -40,9 +44,32 @@ func run_tests(params: Dictionary) -> Dictionary:
var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
if not discovery.errors.is_empty():
results["load_errors"] = discovery.errors
_annotate_edited_scene(results)
return {"data": results}
## Many suites assume the project's main scene is the edited scene (they read
## /Main/... nodes directly). Running with another scene open produces a flood
## of phantom failures that look like real regressions. Surface the edited
## scene and a warning when it differs from run/main_scene so the failures are
## attributable at a glance instead of costing a debugging round (#635).
func _annotate_edited_scene(results: Dictionary) -> void:
var scene_root := EditorInterface.get_edited_scene_root()
var edited := scene_root.scene_file_path if scene_root else ""
results["edited_scene"] = edited
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
if main_scene.is_empty() or edited == main_scene:
return
if int(results.get("failed", 0)) <= 0:
return
results["scene_warning"] = (
"Edited scene is '%s' but the project main scene is '%s'. Many suites "
% [edited if not edited.is_empty() else "<none>", main_scene]
+ "assume the main scene is open and will report phantom failures "
+ "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
)
func get_test_results(params: Dictionary) -> Dictionary:
var verbose: bool = params.get("verbose", false)
return {"data": _runner.get_results(verbose)}
+163
View File
@@ -0,0 +1,163 @@
@tool
extends RefCounted
## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells
## directly in the editor scene with full undo/redo support.
##
## All ops target TileMapLayer nodes in the currently edited scene by
## scene-relative path (e.g. "/LavaLake20x20/Ground").
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MAX_RECT_FILL_CELLS := 4096
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Set a single tile cell.
## params: {path, source_id, atlas_col, atlas_row, map_x, map_y}
## Returns: {map_x, map_y, source_id, atlas_col, atlas_row}
func set_cell(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0))
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var prev := _capture_cell_state(node, pos)
_undo_redo.create_action("MCP: TileMap set_cell")
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
_undo_redo.commit_action()
return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src,
"atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}}
## Fill a rectangular region with one tile type in a single undo action.
## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h}
## Returns: {cells_filled, rect: {x, y, w, h}}
func set_cells_rect(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0))
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1))
if rw <= 0 or rh <= 0:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh]
)
var cell_count := rw * rh
if cell_count > MAX_RECT_FILL_CELLS:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS]
)
var cells: Array[Vector2i] = []
var snapshot: Array[Dictionary] = []
for x in range(rx, rx + rw):
for y in range(ry, ry + rh):
var pos := Vector2i(x, y)
cells.append(pos)
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
_undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh])
for pos in cells:
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cells_filled": cells.size(),
"rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}}
## Remove all tiles from a TileMapLayer.
## params: {path}
## Returns: {cleared: true}
func clear_layer(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var snapshot := _capture_used_cells_snapshot(node)
_undo_redo.create_action("MCP: TileMap clear")
_undo_redo.add_do_method(node, "clear")
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cleared": true, "undoable": true}}
## Return all used cell coordinates.
## params: {path}
## Returns: {cells: [{x, y}, ...], count: int}
func get_used_cells(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var cells := node.get_used_cells()
var result: Array = []
for c in cells:
result.append({"x": c.x, "y": c.y})
return {"data": {"cells": result, "count": result.size()}}
## Resolve a TileMapLayer node from params["path"] in the currently edited
## scene. Returns {"node": TileMapLayer} on success, or an error dict.
func _resolve_layer(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var scene_file: String = params.get("scene_file", "")
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is TileMapLayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node is not a TileMapLayer: %s" % path)
return {"node": node}
func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary:
var source_id := node.get_cell_source_id(pos)
if source_id == -1:
return {"has_tile": false}
var atlas: Vector2i = node.get_cell_atlas_coords(pos)
var alternative := node.get_cell_alternative_tile(pos)
return {
"has_tile": true,
"source_id": source_id,
"atlas_col": atlas.x,
"atlas_row": atlas.y,
"alternative": alternative,
}
func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]:
var snapshot: Array[Dictionary] = []
for pos in node.get_used_cells():
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
return snapshot
func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
node.clear()
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void:
if not state.get("has_tile", false):
node.erase_cell(pos)
return
node.set_cell(
pos,
int(state.get("source_id", -1)),
Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))),
int(state.get("alternative", 0))
)
@@ -0,0 +1 @@
uid://cm8s7a0ey2q6k
+174
View File
@@ -0,0 +1,174 @@
@tool
extends RefCounted
## TileSet management — atlas inspection helpers.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
func _init() -> void:
pass
## Query all occupied atlas tile positions for a single source.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
##
## Returns:
## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}}
## on success (including empty sources, where tiles=[] and count=0)
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies any resource.
func get_atlas_tiles(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var src: TileSetAtlasSource = resolved.src
var tiles: Array = []
for i in range(src.get_tiles_count()):
var v: Vector2i = src.get_tile_id(i)
tiles.append({"col": v.x, "row": v.y})
return {"data": {"tiles": tiles, "count": tiles.size()}}
## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
## max_size — optional int; if > 0, the image is scaled so its longest
## edge is at most max_size pixels (default 0 = full res)
##
## Returns:
## {"data": {"image_base64": String, "width": int, "height": int,
## "original_width": int, "original_height": int, "format": "png"}}
## on success
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource, or texture is null
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies anything.
func get_atlas_image(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var source_id: int = resolved.source_id
var src: TileSetAtlasSource = resolved.src
var tex: Texture2D = src.texture
if tex == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d has no texture assigned" % source_id
)
var img: Image = tex.get_image()
if img == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Could not retrieve image data from texture of source %d" % source_id
)
if img.is_compressed():
var decompress_err := img.decompress()
if decompress_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)]
)
var original_width: int = img.get_width()
var original_height: int = img.get_height()
var max_size: int = params.get("max_size", 0)
if max_size > 0:
var longest_edge: int = max(original_width, original_height)
if longest_edge > max_size:
var scale: float = float(max_size) / float(longest_edge)
var new_w: int = max(1, int(original_width * scale))
var new_h: int = max(1, int(original_height * scale))
img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
var png_bytes: PackedByteArray = img.save_png_to_buffer()
if png_bytes.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"PNG encoding produced empty output for source %d" % source_id
)
var b64: String = Marshalls.raw_to_base64(png_bytes)
return {
"data": {
"image_base64": b64,
"width": img.get_width(),
"height": img.get_height(),
"original_width": original_width,
"original_height": original_height,
"format": "png",
}
}
func _resolve_atlas_source(params: Dictionary) -> Dictionary:
var tileset_path: String = params.get("tileset_path", "")
if tileset_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'tileset_path' parameter is required and must not be empty"
)
if not params.has("source_id"):
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'source_id' parameter is required"
)
if not ResourceLoader.exists(tileset_path):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"TileSet resource not found: %s" % tileset_path
)
var ts = load(tileset_path)
if not ts is TileSet:
var loaded_type := "null" if ts == null else ts.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type]
)
var source_id: int = int(params.get("source_id", -999))
if source_id < 0 or not ts.has_source(source_id):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"source_id %d does not exist in TileSet" % source_id
)
var src = ts.get_source(source_id)
if not src is TileSetAtlasSource:
var source_type: String = "null" if src == null else src.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type]
)
return {
"source_id": source_id,
"src": src,
}
@@ -0,0 +1 @@
uid://de3v4m1pnk7tr