fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation
This commit is contained in:
@@ -14,7 +14,7 @@ The plugin auto-starts the MCP server and connects over WebSocket. No manual con
|
||||
|
||||
## Requirements
|
||||
|
||||
- Godot 4.3+ (4.4+ recommended)
|
||||
- Godot 4.5+ (4.7+ recommended)
|
||||
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
|
||||
<details>
|
||||
<summary>Install uv</summary>
|
||||
|
||||
@@ -24,6 +24,7 @@ const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
|
||||
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
|
||||
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
|
||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
||||
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
||||
|
||||
const SERVER_NAME := "godot-ai"
|
||||
|
||||
@@ -37,8 +38,14 @@ const DEFAULT_WS_PORT := 9500
|
||||
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
|
||||
const MIN_PORT := 1024
|
||||
const MAX_PORT := 65535
|
||||
## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
|
||||
## pathological run of occupied ports can't stall the (cold-path) caller.
|
||||
## 64 localhost binds are sub-millisecond; finding a free port realistically
|
||||
## takes one or two probes, so this only bounds the worst case.
|
||||
const SUGGEST_PORT_MAX_PROBES := 64
|
||||
const SETTING_WS_PORT := "godot_ai/ws_port"
|
||||
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
|
||||
const _DISCOVERY_TIMEOUT_MS := 3000
|
||||
|
||||
|
||||
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
|
||||
@@ -131,15 +138,39 @@ static func excluded_domains() -> String:
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
## Clamp `start` into the legal port range, then walk
|
||||
## `candidate`..`candidate+span-1` and return the first port that is NOT
|
||||
## currently excluded by Windows' winnat reservation table. Falls back to the
|
||||
## clamped candidate if nothing clears (caller can apply anyway — user may
|
||||
## just retry). On non-Windows this is a no-op: all ports pass, returns the
|
||||
## clamped candidate.
|
||||
## Suggest a port the caller can actually switch to. Walks
|
||||
## `candidate`..`candidate+span-1` and returns the first port that is both
|
||||
## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
|
||||
## grab these; bind fails with WinError 10013 and netstat shows nothing) and
|
||||
## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
|
||||
## "free" honest on macOS/Linux, where the reservation table is empty but the
|
||||
## next port up may still be occupied — the same suggestion feeds the dock
|
||||
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
|
||||
## log line. Falls back to the clamped candidate if nothing in the window
|
||||
## clears both checks (caller surfaces it as a best-effort hint; the user can
|
||||
## retry or pick another). Best-effort by nature: a TOCTOU window remains
|
||||
## between the probe and the caller actually binding the port. The bind probe
|
||||
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
|
||||
## stall on a pathological run of occupied ports.
|
||||
static func suggest_free_port(start: int, span: int = 2048) -> int:
|
||||
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
|
||||
return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT)
|
||||
var limit := mini(candidate + span - 1, MAX_PORT)
|
||||
var p := candidate
|
||||
var probes := 0
|
||||
while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
|
||||
## Jump past a whole Windows-reserved range in one step (no-op on
|
||||
## POSIX: returns `p` unchanged), so we don't probe port-by-port
|
||||
## through the large adjacent ranges those services reserve. The
|
||||
## jump itself runs no bind probes, so it doesn't count against the cap.
|
||||
var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
|
||||
if not_reserved < p or not_reserved > limit:
|
||||
break
|
||||
p = not_reserved
|
||||
probes += 1
|
||||
if PortResolver.can_bind_local_port(p):
|
||||
return p
|
||||
p += 1
|
||||
return candidate
|
||||
|
||||
|
||||
# --- Client operations (string id) ---------------------------------------
|
||||
@@ -509,8 +540,8 @@ static func invalidate_uvx_cli_cache() -> void:
|
||||
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
|
||||
## with a mutex so it can race safely against worker threads calling
|
||||
## `find()` from `_run_client_action_worker`. The mutex is held only
|
||||
## across the dictionary clear, never across `OS.execute`, so this call
|
||||
## can never block the main thread on a subprocess.
|
||||
## across the dictionary clear, never across the bounded subprocess lookup,
|
||||
## so this call can never block the main thread on a subprocess.
|
||||
static func invalidate_cli_cache() -> void:
|
||||
CliFinder.invalidate()
|
||||
|
||||
@@ -521,10 +552,9 @@ static var _uv_version_searched: bool = false
|
||||
|
||||
## Cached for the editor session. The dock's `_refresh_setup_status`
|
||||
## (called via `call_deferred` from `_build_ui`) calls this on the
|
||||
## main thread in user mode, so a single cold `OS.execute(uvx,
|
||||
## ["--version"])` adds ~80 ms to the dock's first paint on Linux and
|
||||
## more on Windows. Subsequent calls (focus-in refresh, manual Refresh
|
||||
## clicks) reuse the cached string.
|
||||
## main thread in user mode, so the cold `uvx --version` probe is
|
||||
## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
|
||||
## manual Refresh clicks) reuse the cached string.
|
||||
##
|
||||
## Invalidate via `invalidate_uv_version_cache()` when the user
|
||||
## installs / reinstalls uv via the dock so the next refresh reflects
|
||||
@@ -539,9 +569,10 @@ static func check_uv_version() -> String:
|
||||
_uv_version_searched = true
|
||||
_uv_version_cache = ""
|
||||
return ""
|
||||
var output: Array = []
|
||||
if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0:
|
||||
_uv_version_cache = output[0].strip_edges()
|
||||
var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||
_uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
|
||||
else:
|
||||
_uv_version_cache = ""
|
||||
_uv_version_searched = true
|
||||
@@ -612,9 +643,12 @@ static func find_worktree_src_dir(start_dir: String) -> String:
|
||||
|
||||
static func _find_system_install() -> String:
|
||||
var cmd := "which" if OS.get_name() != "Windows" else "where"
|
||||
var output: Array = []
|
||||
if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0:
|
||||
var found: String = output[0].strip_edges()
|
||||
var result := McpCliExec.run(cmd, ["godot-ai"], _DISCOVERY_TIMEOUT_MS, false)
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||
if lines.is_empty():
|
||||
return ""
|
||||
var found := CliFinder._pick_best_path(lines) if OS.get_name() == "Windows" else lines[0].strip_edges()
|
||||
if not found.is_empty():
|
||||
return found
|
||||
return ""
|
||||
|
||||
@@ -34,6 +34,7 @@ extends RefCounted
|
||||
|
||||
const DEFAULT_TIMEOUT_MS := 8000
|
||||
const _POLL_INTERVAL_MS := 50
|
||||
const _KILL_GRACE_MS := 500
|
||||
|
||||
|
||||
static func run(
|
||||
@@ -44,6 +45,15 @@ static func run(
|
||||
) -> Dictionary:
|
||||
if exe.is_empty():
|
||||
return _spawn_failed_result()
|
||||
return _run_piped(exe, args, timeout_ms, capture_stderr)
|
||||
|
||||
|
||||
static func _run_piped(
|
||||
exe: String,
|
||||
args: Array,
|
||||
timeout_ms: int,
|
||||
capture_stderr: bool,
|
||||
) -> Dictionary:
|
||||
|
||||
var spawn_exe := exe
|
||||
var spawn_args := args
|
||||
@@ -76,12 +86,18 @@ static func run(
|
||||
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
|
||||
while OS.is_process_running(pid):
|
||||
if Time.get_ticks_msec() >= deadline:
|
||||
## Read whatever made it to the pipes before we kill the
|
||||
## process — partial output beats blank "timed out" when the
|
||||
## CLI was emitting useful diagnostics on its way to hanging.
|
||||
var partial_stdout := _drain_pipe(stdio)
|
||||
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
|
||||
## Kill before draining: a pipe read can block while the child is
|
||||
## still alive. Once it exits, drain any buffered partial output.
|
||||
OS.kill(pid)
|
||||
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
|
||||
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
|
||||
OS.delay_msec(_POLL_INTERVAL_MS)
|
||||
|
||||
var partial_stdout := ""
|
||||
var partial_stderr := ""
|
||||
if not OS.is_process_running(pid):
|
||||
partial_stdout = _drain_pipe(stdio)
|
||||
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
|
||||
_close_pipes(stdio, stderr_pipe)
|
||||
return {
|
||||
"exit_code": -1,
|
||||
@@ -119,9 +135,19 @@ static func _spawn_failed_result() -> Dictionary:
|
||||
|
||||
|
||||
static func _drain_pipe(pipe: Variant) -> String:
|
||||
if pipe is FileAccess:
|
||||
return (pipe as FileAccess).get_as_text()
|
||||
return ""
|
||||
if not (pipe is FileAccess):
|
||||
return ""
|
||||
var f := pipe as FileAccess
|
||||
var bytes := PackedByteArray()
|
||||
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
|
||||
while bytes.size() < max_bytes:
|
||||
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
|
||||
if chunk.is_empty():
|
||||
break
|
||||
bytes.append_array(chunk)
|
||||
if f.eof_reached():
|
||||
break
|
||||
return bytes.get_string_from_utf8()
|
||||
|
||||
|
||||
static func _join_streams(stdout: String, stderr_text: String) -> String:
|
||||
|
||||
@@ -14,7 +14,7 @@ extends RefCounted
|
||||
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
|
||||
## concurrent mutation, so `_cache` / `_searched` access is guarded by
|
||||
## `_mutex`. The mutex is held only across dictionary read/write — the slow
|
||||
## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a
|
||||
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
|
||||
## main-thread `invalidate()` can never block on a worker's subprocess.
|
||||
## Two workers racing the same exe both call `_resolve()` and both write
|
||||
## back the same answer; that's wasted work, not corruption.
|
||||
@@ -24,6 +24,8 @@ static var _mutex: Mutex = Mutex.new()
|
||||
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
|
||||
static var _searched: Dictionary = {}
|
||||
|
||||
const _LOOKUP_TIMEOUT_MS := 3000
|
||||
|
||||
|
||||
## Find any of the supplied exe names; returns the first hit.
|
||||
## On Windows pass the .exe variant in `exe_names` if relevant.
|
||||
@@ -54,8 +56,8 @@ static func _find_one(exe_name: String) -> String:
|
||||
_mutex.unlock()
|
||||
if already_searched:
|
||||
return cached
|
||||
# `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` /
|
||||
# `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
||||
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
# would let a concurrent `invalidate()` on the main thread freeze the
|
||||
# editor for the duration of the subprocess — which defeats the whole
|
||||
# point of running CLI lookup off the main thread.
|
||||
@@ -81,20 +83,19 @@ static func _resolve(exe_name: String) -> String:
|
||||
var shell := OS.get_environment("SHELL")
|
||||
if shell.is_empty():
|
||||
shell = "/bin/bash"
|
||||
var login_output: Array = []
|
||||
var stripped := exe_name.trim_suffix(".exe")
|
||||
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
|
||||
if login_exit == 0 and login_output.size() > 0:
|
||||
var login_found: String = login_output[0].strip_edges()
|
||||
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
|
||||
if int(login_result.get("exit_code", -1)) == 0:
|
||||
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
||||
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
||||
return login_found
|
||||
|
||||
# 3. which / where with inherited PATH
|
||||
var lookup := "where" if is_windows else "which"
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(lookup, [exe_name], output, true)
|
||||
if exit_code == 0 and output.size() > 0:
|
||||
var lines := PackedStringArray(output[0].split("\n"))
|
||||
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var output := str(result.get("stdout", ""))
|
||||
var lines := PackedStringArray(output.split("\n"))
|
||||
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
||||
if not found.is_empty():
|
||||
return found
|
||||
|
||||
@@ -7,13 +7,20 @@ func _init() -> void:
|
||||
display_name = "Antigravity"
|
||||
config_type = "json"
|
||||
doc_url = "https://www.antigravity.dev/"
|
||||
## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
|
||||
## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
|
||||
## old path is left in `detect_paths` below so an existing install is
|
||||
## still recognized, but new/updated entries write to the current path.
|
||||
path_template = {
|
||||
"unix": "~/.gemini/antigravity/mcp_config.json",
|
||||
"windows": "$USERPROFILE/.gemini/antigravity/mcp_config.json",
|
||||
"unix": "~/.gemini/config/mcp_config.json",
|
||||
"windows": "$USERPROFILE/.gemini/config/mcp_config.json",
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
entry_url_field = "serverUrl"
|
||||
## `disabled` is user-state (they may have flipped the entry off in the
|
||||
## UI); seeded on first Configure but preserved across reconfigure.
|
||||
entry_initial_fields = {"disabled": false}
|
||||
detect_paths = PackedStringArray(path_template.values())
|
||||
detect_paths = PackedStringArray(path_template.values() + [
|
||||
"~/.gemini/antigravity/mcp_config.json",
|
||||
"$USERPROFILE/.gemini/antigravity/mcp_config.json",
|
||||
])
|
||||
|
||||
@@ -48,6 +48,12 @@ var server_version := ""
|
||||
|
||||
var dispatcher
|
||||
var log_buffer
|
||||
var surfaced_error_tracker
|
||||
## Set by plugin.gd. Lets the per-frame play-state poll end game-run
|
||||
## bookkeeping when the game exits on its own (self-quit, crash) — the
|
||||
## debugger session's stopped signal is not reliably connected, and no MCP
|
||||
## stop op runs in that path (#642).
|
||||
var debugger_plugin
|
||||
## Set by plugin.gd when the HTTP port is occupied by an incompatible or
|
||||
## unverified server. Keeping the Connection node alive lets handlers and the
|
||||
## dock share one object, but no WebSocket is opened to the wrong server.
|
||||
@@ -89,6 +95,10 @@ func _process(delta: float) -> void:
|
||||
if pause_processing:
|
||||
return
|
||||
_peer.poll()
|
||||
## Run-stop bookkeeping must not wait behind the socket-state machine:
|
||||
## if the game stops while disconnected, the first command drained on
|
||||
## reconnect would still observe stale "live" state (PR #642 review).
|
||||
_check_game_run_play_state(EditorInterface.is_playing_scene())
|
||||
|
||||
match _peer.get_ready_state():
|
||||
WebSocketPeer.STATE_OPEN:
|
||||
@@ -309,6 +319,8 @@ func send_deferred_response(request_id: String, payload: Dictionary) -> void:
|
||||
## `readiness_after` payload field were ever dropped.
|
||||
if not response.has("readiness"):
|
||||
response["readiness"] = get_readiness()
|
||||
if not response.has("error_watermark"):
|
||||
_stamp_error_watermark(response)
|
||||
if _send_json(response) and dispatcher != null:
|
||||
dispatcher.complete_deferred_response(request_id)
|
||||
|
||||
@@ -319,10 +331,15 @@ func _hook_editor_signals() -> void:
|
||||
EditorInterface.get_editor_settings() # ensure interface is ready
|
||||
_last_scene_path = _get_current_scene_path()
|
||||
_last_play_state = EditorInterface.is_playing_scene()
|
||||
_last_play_state_for_run = _last_play_state
|
||||
|
||||
|
||||
var _last_scene_path := ""
|
||||
var _last_play_state := false
|
||||
## Separate edge tracker for game-run bookkeeping: _last_play_state only
|
||||
## advances when the play_state_changed event sends successfully, but ending
|
||||
## run tracking must not depend on the websocket being up.
|
||||
var _last_play_state_for_run := false
|
||||
var _last_readiness := ""
|
||||
|
||||
|
||||
@@ -359,7 +376,22 @@ func _check_state_changes() -> void:
|
||||
if send_event("readiness_changed", {"readiness": readiness}):
|
||||
_last_readiness = readiness
|
||||
if log_buffer:
|
||||
log_buffer.log("[event] readiness -> %s" % readiness)
|
||||
## echo=false: readiness flips on every filesystem scan
|
||||
## (each import cycles importing -> ready), so echoing to
|
||||
## console spams every install during normal editing (#626).
|
||||
## The line stays in the ring for the dock's log panel.
|
||||
log_buffer.log("[event] readiness -> %s" % readiness, false)
|
||||
|
||||
|
||||
## Playing→stopped edge for game-run bookkeeping. Runs every process tick
|
||||
## (any socket state) so a self-quit game's run ends even while the
|
||||
## transport is down or reconnecting.
|
||||
func _check_game_run_play_state(playing: bool) -> void:
|
||||
if playing == _last_play_state_for_run:
|
||||
return
|
||||
if not playing and debugger_plugin != null:
|
||||
debugger_plugin.note_editor_play_stopped()
|
||||
_last_play_state_for_run = playing
|
||||
|
||||
|
||||
func _get_current_scene_path() -> String:
|
||||
@@ -403,6 +435,7 @@ func _handle_outbound_backpressure(
|
||||
return false
|
||||
|
||||
var err_response := _make_backpressure_error(request_id, buffered_bytes, message_bytes)
|
||||
_stamp_error_watermark(err_response)
|
||||
var err_text := JSON.stringify(err_response)
|
||||
var err_bytes := err_text.to_utf8_buffer().size()
|
||||
if _would_exceed_outbound_backpressure(buffered_bytes, err_bytes):
|
||||
@@ -457,6 +490,10 @@ static func _make_backpressure_error(
|
||||
}
|
||||
|
||||
|
||||
func _stamp_error_watermark(response: Dictionary) -> void:
|
||||
McpSurfacedErrorTracker.stamp_watermark(response, surfaced_error_tracker)
|
||||
|
||||
|
||||
## Build a human-readable session ID of form "<slug>@<4hex>" from the project path.
|
||||
## The slug is derived from the project directory name so agents can recognize
|
||||
## which editor they're targeting; the hex suffix disambiguates same-project twins.
|
||||
|
||||
@@ -64,6 +64,8 @@ const EVAL_PROBE_INTERVAL_SEC := 0.35
|
||||
|
||||
var _log_buffer: McpLogBuffer
|
||||
var _game_log_buffer: McpGameLogBuffer
|
||||
var _editor_log_buffer: McpEditorLogBuffer
|
||||
var _surfaced_error_tracker
|
||||
|
||||
## Pending request_id -> {connection, timer, timeout_callable}.
|
||||
## We retain the bound timeout lambda so `_clear_pending` can disconnect
|
||||
@@ -80,12 +82,41 @@ var _game_run_token := 0
|
||||
var _ready_run_token := -1
|
||||
var _game_session_id := -1
|
||||
var _game_run_active := false
|
||||
var _manual_run_armed := false
|
||||
var _game_run_started_msec := 0
|
||||
var _game_run_started_editor_cursor := 0
|
||||
var _game_run_started_debugger_cursor := 0
|
||||
var _game_helper_expected := true
|
||||
|
||||
## #645: a GDScript parse error hit while an editor-launched game boots calls
|
||||
## GDScriptLanguage::debug_break_parse — the game parks in a remote-debugger
|
||||
## break BEFORE the helper's mcp:hello and before any record reaches the
|
||||
## Errors tab, the editor Logger, or the game log. The only editor-side traces
|
||||
## are the debugger break signals; the stack frames land in the Stack Trace
|
||||
## panel a few frames later. Track the break here so game_status can report
|
||||
## status="break" and a synthesized error record can name the failure.
|
||||
var _break_active := false
|
||||
var _break_can_debug := false
|
||||
var _break_reason := ""
|
||||
var _break_pre_live := false
|
||||
var _break_run_token := -1
|
||||
var _break_record_synthesized := false
|
||||
|
||||
## #645: how long after the break signal to scrape the Stack Trace panel for
|
||||
## frames. The editor requests the stack dump from the game separately, so the
|
||||
## panel is empty at signal time; ~0.5s has it populated. The late tick
|
||||
## synthesizes with whatever is available so a scrape failure still yields a
|
||||
## record carrying the break reason.
|
||||
const BREAK_FRAME_SCRAPE_DELAYS_SEC: Array[float] = [0.5, 2.0]
|
||||
|
||||
signal game_ready
|
||||
|
||||
|
||||
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void:
|
||||
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, surfaced_error_tracker = null) -> void:
|
||||
_log_buffer = log_buffer
|
||||
_game_log_buffer = game_log_buffer
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
_surfaced_error_tracker = surfaced_error_tracker
|
||||
|
||||
|
||||
func _has_capture(prefix: String) -> bool:
|
||||
@@ -102,32 +133,512 @@ func _has_capture(prefix: String) -> bool:
|
||||
## plugin.gd's _enter_tree logs "plugin loaded", and ci-reload-test
|
||||
## asserts "plugin loaded" is the first line after a plugin reload.
|
||||
func _setup_session(session_id: int) -> void:
|
||||
_game_ready = false
|
||||
_ready_run_token = -1
|
||||
_connect_session_stopped(session_id)
|
||||
_connect_session_break_signals(session_id)
|
||||
if EditorInterface.is_playing_scene() and not _game_run_active:
|
||||
_begin_game_run_tracking(_editor_log_cursor(), true, true, true, true, true)
|
||||
else:
|
||||
_game_ready = false
|
||||
_ready_run_token = -1
|
||||
_game_session_id = session_id
|
||||
|
||||
|
||||
func begin_game_run() -> void:
|
||||
func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void:
|
||||
_begin_game_run_tracking(editor_log_cursor, helper_expected, true, true)
|
||||
|
||||
|
||||
func _begin_game_run_tracking(
|
||||
editor_log_cursor: int = 0,
|
||||
helper_expected: bool = true,
|
||||
rotate_game_log: bool = true,
|
||||
sticky_debugger_scan: bool = true,
|
||||
quiet: bool = false,
|
||||
manual_armed: bool = false,
|
||||
) -> void:
|
||||
_game_run_token += 1
|
||||
_game_run_active = true
|
||||
_manual_run_armed = manual_armed
|
||||
_game_ready = false
|
||||
_ready_run_token = -1
|
||||
_game_session_id = -1
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] game capture pending run token %d" % _game_run_token)
|
||||
clear_debug_break()
|
||||
_game_run_started_msec = Time.get_ticks_msec()
|
||||
_game_run_started_editor_cursor = maxi(0, editor_log_cursor)
|
||||
if _surfaced_error_tracker != null:
|
||||
_surfaced_error_tracker.note_game_run_started(sticky_debugger_scan)
|
||||
_game_run_started_debugger_cursor = _surfaced_error_tracker.debugger_promoted_total()
|
||||
else:
|
||||
_game_run_started_debugger_cursor = 0
|
||||
_game_helper_expected = helper_expected
|
||||
var run_id := ""
|
||||
if _game_log_buffer and rotate_game_log:
|
||||
run_id = _game_log_buffer.clear_for_new_run()
|
||||
if _log_buffer and not quiet:
|
||||
var log_text := "[debug] game capture pending run token %d" % _game_run_token
|
||||
if not run_id.is_empty():
|
||||
log_text += " (run %s)" % run_id
|
||||
_log_buffer.log(log_text)
|
||||
|
||||
|
||||
func _editor_log_cursor() -> int:
|
||||
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
|
||||
|
||||
|
||||
func end_game_run() -> void:
|
||||
_game_run_active = false
|
||||
_manual_run_armed = false
|
||||
_game_ready = false
|
||||
_ready_run_token = -1
|
||||
_game_session_id = -1
|
||||
clear_debug_break()
|
||||
if _surfaced_error_tracker != null:
|
||||
_surfaced_error_tracker.note_game_run_stopped()
|
||||
|
||||
|
||||
## Authoritative fallback for runs whose debugger `stopped` signal never
|
||||
## fired or was never connected: the editor's play state falling to stopped
|
||||
## means the game process is gone. A game that exits on its own
|
||||
## (get_tree().quit(), crash) has no MCP stop op to run the bookkeeping, and
|
||||
## without this game_status stayed "live" until the next run (#642 smoke).
|
||||
## Called on the playing→stopped edge only, so the pre-play launch window
|
||||
## (run tracking begun, is_playing_scene() not yet true) is never clipped.
|
||||
func note_editor_play_stopped() -> void:
|
||||
if not _game_run_active:
|
||||
return
|
||||
end_game_run()
|
||||
|
||||
|
||||
func _connect_session_stopped(session_id: int) -> void:
|
||||
var session = get_session(session_id)
|
||||
if session == null:
|
||||
return
|
||||
var stopped := Callable(self, "_on_debugger_session_stopped").bind(session_id)
|
||||
if not session.stopped.is_connected(stopped):
|
||||
session.stopped.connect(stopped)
|
||||
|
||||
|
||||
func _on_debugger_session_stopped(session_id: int) -> void:
|
||||
if _game_session_id != -1 and session_id != _game_session_id:
|
||||
return
|
||||
## MCP-started runs normally end via project_manage(op="stop"), but a game
|
||||
## that exits on its own (get_tree().quit(), crash) emits only this signal.
|
||||
## Without ending the run here, game_status stays "live" until the next
|
||||
## run's bookkeeping rewrites it (#642 live smoke). Before the game session
|
||||
## attaches (_game_session_id == -1) only manual runs may end on this
|
||||
## signal — a foreign session's stop must not cancel a launching MCP run.
|
||||
if not _manual_run_armed and _game_session_id == -1:
|
||||
return
|
||||
end_game_run()
|
||||
|
||||
|
||||
## --- #645: boot-time debugger breaks ---------------------------------------
|
||||
|
||||
func _connect_session_break_signals(session_id: int) -> void:
|
||||
var session = get_session(session_id)
|
||||
if session != null:
|
||||
var breaked_cb := Callable(self, "_on_debugger_session_breaked").bind(session_id)
|
||||
if not session.breaked.is_connected(breaked_cb):
|
||||
session.breaked.connect(breaked_cb)
|
||||
var continued_cb := Callable(self, "_on_debugger_session_continued").bind(session_id)
|
||||
if not session.continued.is_connected(continued_cb):
|
||||
session.continued.connect(continued_cb)
|
||||
_connect_script_debugger_breaked()
|
||||
|
||||
|
||||
## The session-level `breaked` signal carries only can_debug; the underlying
|
||||
## ScriptEditorDebugger's own `breaked` also carries the human-readable break
|
||||
## reason ("Parser Error: ..."), which is otherwise visible only in the
|
||||
## Debugger UI. EditorDebuggerSession does not expose its debugger node, so
|
||||
## locate ScriptEditorDebugger instances by walking the editor UI — the same
|
||||
## approach as the tracker's Errors-tab scrape.
|
||||
func _connect_script_debugger_breaked() -> void:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return
|
||||
var debuggers: Array[Node] = []
|
||||
_collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
|
||||
for dbg in debuggers:
|
||||
if not dbg.has_signal("breaked"):
|
||||
continue
|
||||
var cb := Callable(self, "_on_script_debugger_breaked")
|
||||
if not dbg.is_connected("breaked", cb):
|
||||
dbg.connect("breaked", cb)
|
||||
|
||||
|
||||
static func _collect_nodes_of_class(node: Node, klass: String, out: Array[Node]) -> void:
|
||||
if node.get_class() == klass:
|
||||
out.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_nodes_of_class(child, klass, out)
|
||||
|
||||
|
||||
func _on_debugger_session_breaked(can_debug: bool, session_id: int) -> void:
|
||||
if _game_session_id != -1 and session_id != _game_session_id:
|
||||
return
|
||||
note_debug_break(can_debug, "")
|
||||
|
||||
|
||||
func _on_debugger_session_continued(session_id: int) -> void:
|
||||
if _game_session_id != -1 and session_id != _game_session_id:
|
||||
return
|
||||
clear_debug_break()
|
||||
|
||||
|
||||
## reallydid=false is Godot's "left the break" notification (debug_exit).
|
||||
func _on_script_debugger_breaked(reallydid: bool, can_debug: bool, reason: String, _has_stackdump: bool) -> void:
|
||||
if not reallydid:
|
||||
clear_debug_break()
|
||||
return
|
||||
note_debug_break(can_debug, reason)
|
||||
|
||||
|
||||
## Record that the game process is parked in a remote-debugger break. Fires
|
||||
## once per break from the session signal (no reason text) and again moments
|
||||
## later from the ScriptEditorDebugger signal (with reason) — the notices
|
||||
## merge into one break. Public so tests can drive break state directly.
|
||||
func note_debug_break(can_debug: bool, reason: String) -> void:
|
||||
var first_notice := not _break_active
|
||||
_break_active = true
|
||||
_break_can_debug = can_debug
|
||||
if not reason.is_empty():
|
||||
_break_reason = reason
|
||||
if not first_notice:
|
||||
return
|
||||
_break_run_token = _game_run_token
|
||||
_break_pre_live = _game_run_active and not is_game_capture_ready()
|
||||
_break_record_synthesized = false
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] debugger break (pre_live=%s can_debug=%s)" % [str(_break_pre_live), str(can_debug)])
|
||||
if _break_pre_live:
|
||||
_schedule_break_record_synthesis()
|
||||
|
||||
|
||||
func clear_debug_break() -> void:
|
||||
_break_active = false
|
||||
_break_can_debug = false
|
||||
_break_reason = ""
|
||||
_break_pre_live = false
|
||||
_break_run_token = -1
|
||||
_break_record_synthesized = false
|
||||
|
||||
|
||||
## Stack frames land in the Stack Trace panel a few frames after the break
|
||||
## signal (the editor requests the stack dump separately), so the record is
|
||||
## synthesized on short timers rather than at signal time.
|
||||
func _schedule_break_record_synthesis() -> void:
|
||||
var tree := Engine.get_main_loop() as SceneTree
|
||||
if tree == null:
|
||||
return
|
||||
var token := _break_run_token
|
||||
for i in BREAK_FRAME_SCRAPE_DELAYS_SEC.size():
|
||||
var final := i == BREAK_FRAME_SCRAPE_DELAYS_SEC.size() - 1
|
||||
var timer := tree.create_timer(BREAK_FRAME_SCRAPE_DELAYS_SEC[i])
|
||||
timer.timeout.connect(func() -> void: _on_break_scrape_tick(token, final))
|
||||
|
||||
|
||||
func _on_break_scrape_tick(run_token: int, final: bool) -> void:
|
||||
if not _break_active or _break_record_synthesized or run_token != _break_run_token:
|
||||
return
|
||||
var frames := _scrape_break_stack_frames()
|
||||
if frames.is_empty() and not final:
|
||||
return
|
||||
synthesize_break_error_record(frames)
|
||||
|
||||
|
||||
## Read the debugger's Stack Trace panel rows. Row metadata is a Dictionary
|
||||
## {frame, file, function, line} regardless of editor locale, so stack trees
|
||||
## are identified by metadata shape rather than the translated column title.
|
||||
func _scrape_break_stack_frames() -> Array[Dictionary]:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return []
|
||||
var debuggers: Array[Node] = []
|
||||
_collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
|
||||
for dbg in debuggers:
|
||||
var trees: Array[Node] = []
|
||||
_collect_nodes_of_class(dbg, "Tree", trees)
|
||||
for t in trees:
|
||||
var frames := _frames_from_stack_tree(t as Tree)
|
||||
if not frames.is_empty():
|
||||
return frames
|
||||
return []
|
||||
|
||||
|
||||
static func _frames_from_stack_tree(tree: Tree) -> Array[Dictionary]:
|
||||
var frames: Array[Dictionary] = []
|
||||
var root := tree.get_root()
|
||||
if root == null:
|
||||
return frames
|
||||
var item := root.get_first_child()
|
||||
while item != null:
|
||||
var meta = item.get_metadata(0)
|
||||
if not (meta is Dictionary and meta.has("file") and meta.has("frame")):
|
||||
return []
|
||||
frames.append({
|
||||
"path": str(meta.get("file", "")),
|
||||
"line": int(meta.get("line", 0)),
|
||||
"function": str(meta.get("function", "")),
|
||||
})
|
||||
item = item.get_next()
|
||||
return frames
|
||||
|
||||
|
||||
## Build the Errors-tab-shaped record for a boot-time break and promote it via
|
||||
## the tracker so recent_editor_errors_since / logs_read / the watermark all
|
||||
## surface it — the break itself produces no record anywhere else (#645).
|
||||
## Public so tests can synthesize without waiting on scrape timers.
|
||||
func synthesize_break_error_record(frames: Array[Dictionary]) -> void:
|
||||
if _break_record_synthesized:
|
||||
return
|
||||
_break_record_synthesized = true
|
||||
if _surfaced_error_tracker == null:
|
||||
return
|
||||
var reason := _break_reason
|
||||
if reason.is_empty():
|
||||
reason = "Game process broke into the debugger during startup (script parse/load error; reason not captured)"
|
||||
var top: Dictionary = frames[0] if not frames.is_empty() else {}
|
||||
var location := {
|
||||
"path": str(top.get("path", "")),
|
||||
"line": int(top.get("line", 0)),
|
||||
"function": str(top.get("function", "")),
|
||||
}
|
||||
var entry := {
|
||||
"source": "editor",
|
||||
"level": "error",
|
||||
"text": reason,
|
||||
"path": location["path"],
|
||||
"line": location["line"],
|
||||
"function": location["function"],
|
||||
"details": {
|
||||
"debugger_tab": "Stack Trace",
|
||||
"message": reason,
|
||||
"error_type_name": "debugger_break",
|
||||
"source": location.duplicate(true),
|
||||
"resolved": location.duplicate(true),
|
||||
"frames": frames.duplicate(true),
|
||||
},
|
||||
}
|
||||
_surfaced_error_tracker.record_synthetic_error(entry)
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] synthesized boot-break error record: %s" % McpSurfacedErrorTracker.format_editor_error_summary(entry))
|
||||
|
||||
|
||||
## --- end #645 ---------------------------------------------------------------
|
||||
|
||||
|
||||
func is_game_capture_ready() -> bool:
|
||||
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
|
||||
|
||||
|
||||
static func with_liveness_flags(status: Dictionary) -> Dictionary:
|
||||
var enriched := status.duplicate(true)
|
||||
var state := str(enriched.get("status", "stopped"))
|
||||
enriched["helper_live"] = state == "live"
|
||||
enriched["session_active"] = not state in ["not_live", "stopped"]
|
||||
return enriched
|
||||
|
||||
|
||||
func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary:
|
||||
var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec
|
||||
var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0))
|
||||
var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0
|
||||
## "stopped" also covers idle/never-ran; no game run is currently active.
|
||||
var status := "stopped"
|
||||
if _game_run_active:
|
||||
## #645: a parked process takes precedence over "live" — a game frozen
|
||||
## in a remote-debugger break cannot service game-side tools even when
|
||||
## its helper registered before the break.
|
||||
if _break_active:
|
||||
status = "break"
|
||||
elif is_game_capture_ready():
|
||||
status = "live"
|
||||
elif not _game_helper_expected:
|
||||
status = "no_helper"
|
||||
elif elapsed_msec >= ready_wait_msec:
|
||||
status = "not_live"
|
||||
else:
|
||||
status = "launching"
|
||||
var out := {
|
||||
"status": status,
|
||||
"run_token": _game_run_token,
|
||||
"active": _game_run_active,
|
||||
"ready": is_game_capture_ready(),
|
||||
"helper_expected": _game_helper_expected,
|
||||
"run_started_msec": _game_run_started_msec,
|
||||
"elapsed_msec": elapsed_msec,
|
||||
"ready_wait_msec": ready_wait_msec,
|
||||
"editor_log_cursor": _game_run_started_editor_cursor,
|
||||
}
|
||||
if status == "break":
|
||||
out["break"] = {
|
||||
"reason": _break_reason,
|
||||
"can_debug": _break_can_debug,
|
||||
"pre_live": _break_pre_live,
|
||||
}
|
||||
return with_liveness_flags(out)
|
||||
|
||||
|
||||
func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary:
|
||||
var state := str(status.get("status", "stopped"))
|
||||
var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||
var recent_errors: Array = errors_info.get("errors", [])
|
||||
var recent_errors_scope := str(errors_info.get("scope", "none"))
|
||||
var truncated := bool(errors_info.get("truncated", false))
|
||||
var data := {
|
||||
"game_status": status.duplicate(true),
|
||||
"recent_errors": recent_errors,
|
||||
"recent_errors_scope": recent_errors_scope,
|
||||
"recent_errors_may_predate_run": recent_errors_scope == "retained_recent",
|
||||
"recent_errors_truncated": truncated,
|
||||
}
|
||||
data.merge(split_errors_by_scope(recent_errors, recent_errors_scope), true)
|
||||
var message := ""
|
||||
match state:
|
||||
"not_live":
|
||||
if not recent_errors.is_empty() and recent_errors_scope == "run":
|
||||
message = "The game failed to load or crashed 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:
|
||||
message += " Editor logs since this run may be truncated; showing retained errors."
|
||||
elif not recent_errors.is_empty():
|
||||
message = "The game is not responding and reported no load errors during this run. 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:
|
||||
message = "The game is not responding and reported no load errors 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 retry."
|
||||
"break":
|
||||
var break_info: Dictionary = status.get("break", {})
|
||||
var break_reason := str(break_info.get("reason", ""))
|
||||
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
|
||||
if bool(break_info.get("pre_live", true)):
|
||||
message = "The game hit a script error during startup and is frozen at a debugger break%s. It cannot become live; call project_manage(op='stop') to end the run, fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % reason_suffix
|
||||
else:
|
||||
message = "The game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
|
||||
"no_helper":
|
||||
message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game."
|
||||
"launching":
|
||||
message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0)
|
||||
"stopped":
|
||||
message = "The game is not running. Start the project and retry the game-side tool."
|
||||
_:
|
||||
message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state
|
||||
var err := ErrorCodes.make(code, message)
|
||||
var inner: Dictionary = err.get("error", {})
|
||||
inner["data"] = data
|
||||
err["error"] = inner
|
||||
return err
|
||||
|
||||
|
||||
static func split_errors_by_scope(recent_errors: Array, scope: String) -> Dictionary:
|
||||
var current_run_errors: Array = []
|
||||
var retained_errors: Array = []
|
||||
if scope == "run":
|
||||
current_run_errors = recent_errors
|
||||
elif scope == "retained_recent":
|
||||
retained_errors = recent_errors
|
||||
return {
|
||||
"current_run_errors": current_run_errors,
|
||||
"retained_errors": retained_errors,
|
||||
}
|
||||
|
||||
|
||||
## `force_debugger_scan` bypasses the tracker's scan gate for one read. Keep it
|
||||
## false on per-frame polling paths (the run-liveness loop) — a forced scan
|
||||
## walks the Debugger dock UI — and pass true only for one-shot reads that must
|
||||
## see rows which landed after the last gated scan (#641).
|
||||
func recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
|
||||
return _recent_editor_errors_since(cursor, force_debugger_scan)
|
||||
|
||||
|
||||
func _recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var truncated := false
|
||||
if _surfaced_error_tracker != null:
|
||||
var captured_by_tracker: Dictionary = _surfaced_error_tracker.editor_entries_since(
|
||||
maxi(0, cursor),
|
||||
_game_run_started_debugger_cursor,
|
||||
force_debugger_scan,
|
||||
)
|
||||
truncated = bool(captured_by_tracker.get("truncated", false))
|
||||
for raw_entry in captured_by_tracker.get("entries", []):
|
||||
var compact := _compact_editor_error(raw_entry)
|
||||
if compact.is_empty():
|
||||
continue
|
||||
out.append(compact)
|
||||
if out.size() >= 5:
|
||||
break
|
||||
if not out.is_empty():
|
||||
return {"errors": out, "truncated": truncated, "scope": "run"}
|
||||
for raw_entry in _surfaced_error_tracker.retained_recent_editor_entries():
|
||||
var compact := _compact_editor_error(raw_entry, true)
|
||||
if compact.is_empty():
|
||||
continue
|
||||
out.append(compact)
|
||||
if out.size() >= 5:
|
||||
break
|
||||
if not out.is_empty():
|
||||
return {"errors": out, "truncated": false, "scope": "retained_recent"}
|
||||
return {"errors": out, "truncated": false, "scope": "none"}
|
||||
if _editor_log_buffer == null:
|
||||
return {"errors": out, "truncated": false, "scope": "none"}
|
||||
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1)
|
||||
truncated = bool(captured.get("truncated", false))
|
||||
for raw_entry in captured.get("entries", []):
|
||||
var compact := _compact_editor_error(raw_entry)
|
||||
if compact.is_empty():
|
||||
continue
|
||||
out.append(compact)
|
||||
if out.size() >= 5:
|
||||
break
|
||||
if not out.is_empty():
|
||||
return {"errors": out, "truncated": truncated, "scope": "run"}
|
||||
|
||||
for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)):
|
||||
var compact := _compact_editor_error(raw_entry, true)
|
||||
if compact.is_empty():
|
||||
continue
|
||||
out.append(compact)
|
||||
if out.size() >= 5:
|
||||
break
|
||||
if not out.is_empty():
|
||||
return {"errors": out, "truncated": false, "scope": "retained_recent"}
|
||||
return {"errors": out, "truncated": false, "scope": "none"}
|
||||
|
||||
|
||||
func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary:
|
||||
if not raw_entry is Dictionary:
|
||||
return {}
|
||||
var entry := raw_entry as Dictionary
|
||||
if str(entry.get("level", "info")) != "error":
|
||||
return {}
|
||||
var path := str(entry.get("path", ""))
|
||||
if fallback_recent and _is_diagnostic_noise_path(path):
|
||||
return {}
|
||||
var compact := {
|
||||
"source": "editor",
|
||||
"level": "error",
|
||||
"text": str(entry.get("text", "")),
|
||||
"path": path,
|
||||
"line": int(entry.get("line", 0)),
|
||||
"function": str(entry.get("function", "")),
|
||||
}
|
||||
if entry.has("details"):
|
||||
compact["details"] = entry["details"].duplicate(true)
|
||||
return compact
|
||||
|
||||
|
||||
func _is_diagnostic_noise_path(path: String) -> bool:
|
||||
return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/")
|
||||
|
||||
|
||||
func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(entries.size() - 1, -1, -1):
|
||||
out.append(entries[i])
|
||||
return out
|
||||
|
||||
|
||||
func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
|
||||
|
||||
|
||||
func _capture(message: String, data: Array, session_id: int) -> bool:
|
||||
## Godot passes the full "prefix:tail" string as `message`.
|
||||
match message:
|
||||
@@ -152,18 +663,22 @@ func _capture(message: String, data: Array, session_id: int) -> bool:
|
||||
## Boot beacon from the game-side autoload. Tells us the
|
||||
## game has registered its "mcp" capture and is safe to send
|
||||
## take_screenshot to — before this, Godot's debugger would
|
||||
## drop our message silently. Also marks a fresh play
|
||||
## cycle: rotate the game-log buffer so each run starts
|
||||
## clean and gets a new run_id.
|
||||
## drop our message silently.
|
||||
_game_ready = true
|
||||
_ready_run_token = _game_run_token
|
||||
game_ready.emit()
|
||||
if _game_log_buffer:
|
||||
var run_id := _game_log_buffer.clear_for_new_run()
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id)
|
||||
elif _log_buffer:
|
||||
_log_buffer.log("[debug] <- mcp:hello from game_helper")
|
||||
## #641: boot-time parse errors race the hello beacon — both ride
|
||||
## the same debugger channel, and the editor inserts Errors-tab
|
||||
## rows with a per-frame throttle, so rows can land moments after
|
||||
## the run is declared live. Arm forced scans so those rows get
|
||||
## promoted into the watermark even if no tool call follows.
|
||||
if _surfaced_error_tracker != null:
|
||||
_surfaced_error_tracker.schedule_deferred_scans()
|
||||
if _log_buffer:
|
||||
if _game_log_buffer:
|
||||
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id())
|
||||
else:
|
||||
_log_buffer.log("[debug] <- mcp:hello from game_helper")
|
||||
return true
|
||||
"mcp:eval_response":
|
||||
_on_eval_response(data)
|
||||
@@ -259,11 +774,17 @@ func _wait_then_send(
|
||||
timeout_sec: float,
|
||||
) -> void:
|
||||
var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
|
||||
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
||||
## #645: always yield at least one frame — the dispatcher registers the
|
||||
## deferred request only after the handler returns DEFERRED_RESPONSE, so a
|
||||
## same-frame error reply would be dropped as an expired request. The break
|
||||
## check then bails out with the actionable break error instead of waiting
|
||||
## out the full window (a game parked in a debugger break never beacons).
|
||||
await tree.process_frame
|
||||
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
|
||||
await tree.process_frame
|
||||
if not is_game_capture_ready():
|
||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
||||
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC))
|
||||
_send_error_response(connection, request_id,
|
||||
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
|
||||
return
|
||||
_send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
|
||||
|
||||
@@ -357,16 +878,23 @@ func _on_timeout(request_id: String) -> void:
|
||||
var connection: McpConnection = pending.connection
|
||||
if connection == null or not is_instance_valid(connection):
|
||||
return
|
||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
||||
"Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.")
|
||||
var status := get_game_status(-1, GAME_READY_WAIT_SEC)
|
||||
var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||
"Game screenshot timed out after reaching the game helper. The game may be busy or unable to render a frame. Check logs_read(source='game') and retry.")
|
||||
if status.get("status", "") != "live":
|
||||
err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR)
|
||||
_send_error_response(connection, request_id, err)
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id)
|
||||
|
||||
|
||||
func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void:
|
||||
_send_error_response(connection, request_id, ErrorCodes.make(code, message))
|
||||
|
||||
|
||||
func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void:
|
||||
if connection == null or not is_instance_valid(connection):
|
||||
return
|
||||
var err := ErrorCodes.make(code, message)
|
||||
connection.send_deferred_response(request_id, err)
|
||||
|
||||
|
||||
@@ -440,15 +968,20 @@ func _wait_then_eval(
|
||||
## the not-ready path returns its actionable error before the 15s server-side
|
||||
## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC.
|
||||
var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0)
|
||||
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
||||
## #645: the leading yield guarantees the dispatcher has registered the
|
||||
## deferred request before any reply (a same-frame reply is dropped as
|
||||
## expired); the break check bails out early because a parked game never
|
||||
## registers its capture.
|
||||
await tree.process_frame
|
||||
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
|
||||
await tree.process_frame
|
||||
if not is_game_capture_ready():
|
||||
## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up
|
||||
## but the game-side capture didn't register within the short wait. Fast
|
||||
## and caller-actionable; classifying it apart from the opaque 10s hang
|
||||
## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
|
||||
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
|
||||
"Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC))
|
||||
_send_error_response(connection, request_id,
|
||||
_explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY))
|
||||
return
|
||||
_send_eval(tree, code, request_id, connection, timeout_sec)
|
||||
|
||||
@@ -701,11 +1234,16 @@ func _wait_then_game_command(
|
||||
timeout_sec: float,
|
||||
) -> void:
|
||||
var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
|
||||
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
||||
## #645: the leading yield guarantees the dispatcher has registered the
|
||||
## deferred request before any reply (a same-frame reply is dropped as
|
||||
## expired); the break check bails out early because a parked game never
|
||||
## registers its capture.
|
||||
await tree.process_frame
|
||||
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
|
||||
await tree.process_frame
|
||||
if not is_game_capture_ready():
|
||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
||||
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC))
|
||||
_send_error_response(connection, request_id,
|
||||
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
|
||||
return
|
||||
_send_game_command(tree, op, params, request_id, connection, timeout_sec)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ var _command_queue: Array[Dictionary] = []
|
||||
var _handlers: Dictionary = {} # command_name -> Callable
|
||||
var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
|
||||
var _log_buffer
|
||||
var _surfaced_error_tracker
|
||||
var mcp_logging := true
|
||||
var deferred_timeout_overrides_ms: Dictionary = {}
|
||||
|
||||
@@ -16,16 +17,19 @@ const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
|
||||
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
|
||||
"create_script": 4500,
|
||||
"stop_project": 4500,
|
||||
"run_project": 6000,
|
||||
"take_screenshot": 30000,
|
||||
"game_eval": 15000,
|
||||
"game_command": 15000,
|
||||
"scan_filesystem": 30000,
|
||||
}
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
|
||||
|
||||
|
||||
func _init(log_buffer: McpLogBuffer) -> void:
|
||||
func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void:
|
||||
_log_buffer = log_buffer
|
||||
_surfaced_error_tracker = surfaced_error_tracker
|
||||
|
||||
|
||||
## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
|
||||
@@ -41,6 +45,11 @@ func clear() -> void:
|
||||
_command_queue.clear()
|
||||
_pending_deferred.clear()
|
||||
_log_buffer = null
|
||||
_surfaced_error_tracker = null
|
||||
|
||||
|
||||
func set_surfaced_error_tracker(surfaced_error_tracker) -> void:
|
||||
_surfaced_error_tracker = surfaced_error_tracker
|
||||
|
||||
|
||||
## Invoke a registered handler directly by name. Returns the handler's raw
|
||||
@@ -160,6 +169,7 @@ func _dispatch(cmd: Dictionary) -> Dictionary:
|
||||
## See connection.gd::send_deferred_response for the deferred-response
|
||||
## counterpart, which stamps the same field.
|
||||
result["readiness"] = McpConnection.get_readiness()
|
||||
_stamp_error_watermark(result)
|
||||
|
||||
if mcp_logging:
|
||||
var status: String = result.get("status", "ok")
|
||||
@@ -251,22 +261,22 @@ func _collect_deferred_timeouts() -> Array[Dictionary]:
|
||||
## dispatcher emits so the server cache can't drift just because
|
||||
## the editor happened to time out a deferred command.
|
||||
response["readiness"] = McpConnection.get_readiness()
|
||||
_stamp_error_watermark(response)
|
||||
responses.append(response)
|
||||
if mcp_logging and _log_buffer != null:
|
||||
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
|
||||
return responses
|
||||
|
||||
|
||||
func _stamp_error_watermark(response: Dictionary) -> void:
|
||||
McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
|
||||
|
||||
|
||||
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
|
||||
# Use Engine.call() instead of a direct Engine.capture_script_backtraces()
|
||||
# reference: the method is Godot 4.4+, and 4.3's GDScript parser type-checks
|
||||
# the static call against GDScriptNativeClass at parse time and rejects the
|
||||
# whole script even when guarded by has_method() at runtime.
|
||||
if Engine.has_method("capture_script_backtraces"):
|
||||
var traces: Array = Engine.call("capture_script_backtraces", false)
|
||||
for bt in traces:
|
||||
if bt != null and not bt.is_empty():
|
||||
return _trim_backtrace_string(bt.format(0, 2), max_frames)
|
||||
var traces: Array = Engine.capture_script_backtraces(false)
|
||||
for bt in traces:
|
||||
if bt != null and not bt.is_empty():
|
||||
return _trim_backtrace_string(bt.format(0, 2), max_frames)
|
||||
return _format_stack_frames(get_stack(), max_frames)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ extends VBoxContainer
|
||||
signal logging_enabled_changed(enabled: bool)
|
||||
|
||||
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
|
||||
## Preload (not the McpSettings class_name) for consistency with the parse
|
||||
## hazard note on `_log_buffer` below.
|
||||
const Settings := preload("res://addons/godot_ai/utils/settings.gd")
|
||||
|
||||
## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at
|
||||
## script-load and trips the self-update parse hazard (#398). The type fence
|
||||
@@ -50,7 +53,9 @@ func _build_ui() -> void:
|
||||
|
||||
_log_toggle = CheckButton.new()
|
||||
_log_toggle.text = "Log"
|
||||
_log_toggle.button_pressed = true
|
||||
## Restore the persisted choice — a hardcoded `true` here meant the
|
||||
## toggle reset to noisy on every editor restart (#626).
|
||||
_log_toggle.button_pressed = Settings.mcp_logging_enabled()
|
||||
_log_toggle.toggled.connect(_on_log_toggled)
|
||||
log_header_row.add_child(_log_toggle)
|
||||
|
||||
@@ -62,6 +67,7 @@ func _build_ui() -> void:
|
||||
_log_display.scroll_following = true
|
||||
_log_display.bbcode_enabled = false
|
||||
_log_display.selection_enabled = true
|
||||
_log_display.visible = _log_toggle.button_pressed
|
||||
add_child(_log_display)
|
||||
|
||||
|
||||
@@ -91,5 +97,6 @@ func tick() -> void:
|
||||
|
||||
|
||||
func _on_log_toggled(enabled: bool) -> void:
|
||||
Settings.set_mcp_logging_enabled(enabled)
|
||||
_log_display.visible = enabled
|
||||
logging_enabled_changed.emit(enabled)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+301
-54
@@ -40,8 +40,17 @@ const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.g
|
||||
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
|
||||
|
||||
const DEV_MODE_SETTING := "godot_ai/dev_mode"
|
||||
## "Change the port + reconfigure your clients" guide. Surfaced from the crash
|
||||
## panel when a foreign process holds the HTTP port — the one piece of recovery
|
||||
## (per-client config rewrite) that doesn't fit in the inline crash body.
|
||||
## Resolved against the installed plugin version at click time (see
|
||||
## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
|
||||
## not tip-of-main, which may have drifted from that build's UI.
|
||||
const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
|
||||
const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
|
||||
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
|
||||
const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000
|
||||
const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000
|
||||
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
|
||||
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
|
||||
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
|
||||
@@ -60,15 +69,16 @@ var _status_icon: ColorRect
|
||||
var _status_label: Label
|
||||
var _client_grid: VBoxContainer
|
||||
var _client_configure_all_btn: Button
|
||||
var _client_empty_cta_btn: Button
|
||||
var _clients_summary_label: Label
|
||||
var _clients_window: Window
|
||||
var _dev_mode_toggle: CheckButton
|
||||
var _install_label: Label
|
||||
|
||||
# Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients
|
||||
# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients
|
||||
# that cap total tool count (Antigravity: 100). Pending set is mutated by
|
||||
# checkbox clicks; saved set reflects what the spawned server actually
|
||||
# sees. `Apply & Restart Server` writes pending → setting and triggers a
|
||||
# sees. `Apply and Restart Server` writes pending → setting and triggers a
|
||||
# plugin reload so the new server comes up with the trimmed list.
|
||||
var _tools_pending_excluded: PackedStringArray = PackedStringArray()
|
||||
var _tools_saved_excluded: PackedStringArray = PackedStringArray()
|
||||
@@ -147,23 +157,24 @@ static var _orphaned_client_status_refresh_threads: Array[Thread] = []
|
||||
|
||||
## Per-row worker state for Configure / Remove. Issue #239: shelling out
|
||||
## to a hung CLI on main hangs the editor. We dispatch each click to its
|
||||
## own thread (one slot per client) and apply the result via call_deferred
|
||||
## once the subprocess returns or the wall-clock budget in McpCliExec
|
||||
## kicks in. The buttons stay disabled while the slot is busy so the user
|
||||
## can't queue a re-click on the same row.
|
||||
## own thread (one slot per client), then `_process` reaps completed workers
|
||||
## and applies returned payloads on main. The buttons stay disabled while
|
||||
## the slot is busy so the user can't queue a re-click on the same row.
|
||||
##
|
||||
## Per-client (not single-slot) so Configure-all can fan out — the
|
||||
## workers are independent, only the row UI is shared, and McpCliExec
|
||||
## bounds the wall-clock for each.
|
||||
##
|
||||
## No orphan-thread list (unlike the refresh worker): action threads
|
||||
## never get abandoned mid-flight. McpCliExec's wall-clock budget caps
|
||||
## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager`
|
||||
## install-time drain blocks briefly and finishes — there's no path that
|
||||
## "gives up" on an action thread the way `_abandon_client_status_refresh_thread`
|
||||
## does for the refresh worker.
|
||||
## A watchdog can abandon a slot when a worker fails to report completion.
|
||||
## The thread object is retained in `_orphaned_client_action_threads` until
|
||||
## it finishes so GDScript does not destroy a live Thread object.
|
||||
var _client_action_threads: Dictionary = {}
|
||||
var _client_action_generations: Dictionary = {}
|
||||
var _client_action_started_msec: Dictionary = {}
|
||||
var _client_action_names: Dictionary = {}
|
||||
## Timed-out Configure/Remove workers are abandoned but retained here until
|
||||
## they finish, so GDScript does not destroy a live Thread object.
|
||||
static var _orphaned_client_action_threads: Array[Thread] = []
|
||||
|
||||
# Dev-mode only
|
||||
var _dev_section: VBoxContainer
|
||||
@@ -193,6 +204,11 @@ var _crash_panel: VBoxContainer
|
||||
var _crash_output: RichTextLabel
|
||||
var _crash_restart_btn: Button
|
||||
var _crash_reload_btn: Button
|
||||
## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
|
||||
## case (no `can_recover_incompatible` proof). The inline body names a free
|
||||
## port; this button carries the per-client reconfigure steps that don't fit
|
||||
## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
|
||||
var _crash_docs_btn: Button
|
||||
## Port-picker escape hatch — visible inside the crash panel when the root
|
||||
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
|
||||
## the EditorSetting and reloads the plugin in response to the panel's
|
||||
@@ -238,10 +254,14 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_prune_orphaned_client_status_refresh_threads()
|
||||
_prune_orphaned_client_action_threads()
|
||||
_poll_completed_client_status_refresh_thread()
|
||||
_poll_completed_client_action_threads()
|
||||
_check_client_status_refresh_timeout()
|
||||
_check_client_action_timeouts()
|
||||
if _connection == null:
|
||||
return
|
||||
_prune_orphaned_client_status_refresh_threads()
|
||||
_check_client_status_refresh_timeout()
|
||||
_retry_deferred_client_status_refresh()
|
||||
_update_status()
|
||||
if _log_viewer != null and _log_viewer.visible:
|
||||
@@ -275,6 +295,8 @@ func _exit_tree() -> void:
|
||||
## drains directly because it has additional state-machine work
|
||||
## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit.
|
||||
func prepare_for_self_update_drain() -> void:
|
||||
_poll_completed_client_status_refresh_thread()
|
||||
_poll_completed_client_action_threads()
|
||||
_drain_client_status_refresh_workers()
|
||||
_drain_client_action_workers()
|
||||
|
||||
@@ -309,13 +331,13 @@ func _drain_client_action_workers() -> void:
|
||||
## plugin disable / install-update path reloads our script class, so any
|
||||
## live Thread must finish before its slot is GC'd or we hit
|
||||
## `~Thread … destroyed without its completion having been realized` →
|
||||
## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the
|
||||
## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV.
|
||||
## VM corruption. Normal UI recovery is handled by the per-row watchdog;
|
||||
## teardown still blocks because GDScript's Thread API has no kill/timeout
|
||||
## primitive and destroying a live Thread corrupts the VM.
|
||||
##
|
||||
## Generation-bumped per-row so any pending `call_deferred(
|
||||
## "_apply_client_action_result")` from a worker that finished after we
|
||||
## started draining detects the generation mismatch and short-circuits
|
||||
## without touching freed UI state.
|
||||
## Generation-bumped per-row so any result from a worker that finished
|
||||
## after we started draining detects the generation mismatch and
|
||||
## short-circuits without touching freed UI state.
|
||||
##
|
||||
## After draining, restore the row UI for any in-flight rows: bare
|
||||
## `_client_action_threads.clear()` would leave the dock stuck showing
|
||||
@@ -328,6 +350,8 @@ func _drain_client_action_workers() -> void:
|
||||
if t != null:
|
||||
t.wait_to_finish()
|
||||
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
_finalize_action_buttons(String(client_id))
|
||||
var row: Dictionary = _client_rows.get(String(client_id), {})
|
||||
if not row.is_empty():
|
||||
@@ -337,6 +361,71 @@ func _drain_client_action_workers() -> void:
|
||||
""
|
||||
)
|
||||
_client_action_threads.clear()
|
||||
for thread in _orphaned_client_action_threads:
|
||||
if thread != null:
|
||||
thread.wait_to_finish()
|
||||
_orphaned_client_action_threads.clear()
|
||||
_client_action_started_msec.clear()
|
||||
_client_action_names.clear()
|
||||
|
||||
|
||||
func _check_client_action_timeouts() -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
for client_id in _client_action_threads.keys():
|
||||
if not _client_action_started_msec.has(client_id):
|
||||
continue
|
||||
var started := int(_client_action_started_msec.get(client_id, 0))
|
||||
if now - started >= CLIENT_ACTION_TIMEOUT_MSEC:
|
||||
_abandon_client_action_thread(String(client_id))
|
||||
|
||||
|
||||
func _abandon_client_action_thread(client_id: String) -> void:
|
||||
if not _client_action_threads.has(client_id):
|
||||
return
|
||||
var thread: Thread = _client_action_threads[client_id]
|
||||
var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec()))
|
||||
var worker_alive := thread != null and thread.is_alive()
|
||||
if thread != null:
|
||||
_orphaned_client_action_threads.append(thread)
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
var action := str(_client_action_names.get(client_id, "configure"))
|
||||
_client_action_names.erase(client_id)
|
||||
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
||||
_finalize_action_buttons(client_id)
|
||||
print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [
|
||||
client_id,
|
||||
action,
|
||||
elapsed,
|
||||
str(worker_alive),
|
||||
])
|
||||
var label := "Remove" if action == "remove" else "Configure"
|
||||
_apply_row_status(
|
||||
client_id,
|
||||
Client.Status.ERROR,
|
||||
"%s did not report completion in time; refreshing current status." % label
|
||||
)
|
||||
_refresh_clients_summary()
|
||||
if is_inside_tree():
|
||||
_request_client_status_refresh(true)
|
||||
|
||||
|
||||
func _prune_orphaned_client_action_threads() -> void:
|
||||
var completed_orphan := false
|
||||
for i in range(_orphaned_client_action_threads.size() - 1, -1, -1):
|
||||
var thread := _orphaned_client_action_threads[i]
|
||||
if thread == null:
|
||||
_orphaned_client_action_threads.remove_at(i)
|
||||
elif not thread.is_alive():
|
||||
thread.wait_to_finish()
|
||||
_orphaned_client_action_threads.remove_at(i)
|
||||
completed_orphan = true
|
||||
if completed_orphan and is_inside_tree():
|
||||
_request_client_action_completion_refresh()
|
||||
|
||||
|
||||
func _request_client_action_completion_refresh() -> void:
|
||||
_request_client_status_refresh(true)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
@@ -473,6 +562,13 @@ func _build_ui() -> void:
|
||||
_crash_reload_btn.pressed.connect(_on_reload_plugin)
|
||||
_crash_panel.add_child(_crash_reload_btn)
|
||||
|
||||
_crash_docs_btn = Button.new()
|
||||
_crash_docs_btn.text = "How to change the port"
|
||||
_crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients"
|
||||
_crash_docs_btn.visible = false
|
||||
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
|
||||
_crash_panel.add_child(_crash_docs_btn)
|
||||
|
||||
_crash_panel.add_child(HSeparator.new())
|
||||
add_child(_crash_panel)
|
||||
|
||||
@@ -487,7 +583,7 @@ func _build_ui() -> void:
|
||||
_update_label = Label.new()
|
||||
_update_label.add_theme_font_size_override("font_size", 15)
|
||||
_update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3))
|
||||
## Wrap long banner text (e.g. the < 4.4 manual-update guidance) instead
|
||||
## Wrap long banner text (e.g. the < 4.5 support-floor guidance) instead
|
||||
## of letting a single line stretch the whole dock wide. The dock is a
|
||||
## fixed-width side panel, so constrain horizontally and wrap.
|
||||
_update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
@@ -558,30 +654,45 @@ func _build_ui() -> void:
|
||||
add_child(HSeparator.new())
|
||||
|
||||
# --- Clients ---
|
||||
var clients_row := HBoxContainer.new()
|
||||
clients_row.add_theme_constant_override("separation", 8)
|
||||
var clients_header_row := HBoxContainer.new()
|
||||
clients_header_row.add_theme_constant_override("separation", 8)
|
||||
|
||||
var clients_header := _make_header("Clients")
|
||||
clients_row.add_child(clients_header)
|
||||
clients_header_row.add_child(clients_header)
|
||||
|
||||
_clients_summary_label = Label.new()
|
||||
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
|
||||
_clients_summary_label.clip_text = true
|
||||
_clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
clients_row.add_child(_clients_summary_label)
|
||||
clients_header_row.add_child(_clients_summary_label)
|
||||
|
||||
var clients_actions := HFlowContainer.new()
|
||||
clients_actions.add_theme_constant_override("h_separation", 8)
|
||||
clients_actions.add_theme_constant_override("v_separation", 4)
|
||||
|
||||
var clients_refresh_btn := Button.new()
|
||||
clients_refresh_btn.text = "Refresh"
|
||||
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
|
||||
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
|
||||
clients_row.add_child(clients_refresh_btn)
|
||||
clients_actions.add_child(clients_refresh_btn)
|
||||
|
||||
var clients_open_btn := Button.new()
|
||||
clients_open_btn.text = "Clients & Settings"
|
||||
clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
|
||||
clients_open_btn.text = "Clients & Tools"
|
||||
clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
|
||||
clients_open_btn.pressed.connect(_on_open_clients_window)
|
||||
clients_row.add_child(clients_open_btn)
|
||||
clients_actions.add_child(clients_open_btn)
|
||||
|
||||
add_child(clients_row)
|
||||
add_child(clients_header_row)
|
||||
add_child(clients_actions)
|
||||
|
||||
_client_empty_cta_btn = Button.new()
|
||||
_client_empty_cta_btn.text = "Configure an AI client ->"
|
||||
_client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
|
||||
_client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_client_empty_cta_btn.visible = false
|
||||
_client_empty_cta_btn.pressed.connect(_on_open_clients_window)
|
||||
add_child(_client_empty_cta_btn)
|
||||
|
||||
# Drift banner — hidden until a sweep finds at least one mismatched client.
|
||||
_drift_banner = VBoxContainer.new()
|
||||
@@ -600,7 +711,7 @@ func _build_ui() -> void:
|
||||
add_child(_drift_banner)
|
||||
|
||||
_clients_window = Window.new()
|
||||
_clients_window.title = "MCP Clients & Settings"
|
||||
_clients_window.title = "Godot AI"
|
||||
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
|
||||
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
|
||||
_clients_window.visible = false
|
||||
@@ -758,7 +869,7 @@ func _build_client_row(client_id: String) -> void:
|
||||
# --- Status updates ---
|
||||
|
||||
func _update_status() -> void:
|
||||
var connected: bool = _connection.is_connected
|
||||
var connected: bool = _connection != null and _connection.is_connected
|
||||
## During plugin self-update there's a brief window where this dock
|
||||
## script is already the new version (Godot hot-reloads scripts on
|
||||
## file change) but `_plugin` is still the old `EditorPlugin` instance
|
||||
@@ -785,7 +896,7 @@ func _update_status() -> void:
|
||||
status_text = "Restarting server..."
|
||||
status_color = COLOR_AMBER
|
||||
elif connected:
|
||||
status_text = "Connected"
|
||||
status_text = _connected_status_text()
|
||||
status_color = Color.GREEN
|
||||
elif state == ServerStateScript.CRASHED:
|
||||
var exit_ms: int = server_status.get("exit_ms", 0)
|
||||
@@ -858,6 +969,15 @@ func _update_crash_panel(server_status: Dictionary) -> void:
|
||||
not show_recovery_restart
|
||||
and state != ServerStateScript.INCOMPATIBLE
|
||||
)
|
||||
## Docs link only for the genuinely-foreign occupant: a recoverable
|
||||
## (older godot-ai) server gets Restart Server instead, and the inline
|
||||
## body already names a free port — the link carries the per-client
|
||||
## reconfigure steps that don't fit inline.
|
||||
if _crash_docs_btn != null:
|
||||
_crash_docs_btn.visible = (
|
||||
state == ServerStateScript.INCOMPATIBLE
|
||||
and not bool(server_status.get("can_recover_incompatible", false))
|
||||
)
|
||||
|
||||
var port_picker_visible := (
|
||||
state == ServerStateScript.PORT_EXCLUDED
|
||||
@@ -887,9 +1007,16 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
||||
if not message.is_empty():
|
||||
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
|
||||
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
|
||||
## Genuinely foreign occupant (no recovery proof). Name a concrete
|
||||
## free port so the user doesn't have to hunt for one, and let the
|
||||
## crash panel's "How to change the port" link carry the per-client
|
||||
## reconfigure steps. `suggest_free_port` already routes through the
|
||||
## Windows reservation table, so the named port won't itself fail
|
||||
## with WinError 10013.
|
||||
var hint := _free_port_hint(port)
|
||||
if not message.is_empty():
|
||||
return message
|
||||
return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port
|
||||
return "%s %s" % [message, hint]
|
||||
return "Port %d is occupied by an incompatible server. %s" % [port, hint]
|
||||
ServerStateScript.FOREIGN_PORT:
|
||||
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
|
||||
ServerStateScript.CRASHED:
|
||||
@@ -907,6 +1034,31 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
||||
return ""
|
||||
|
||||
|
||||
## One sentence naming concrete free ports for the user to switch to. Names
|
||||
## BOTH http and ws: this branch also fires for an incompatible godot-ai
|
||||
## server we can't prove we own, which commonly holds both ports — moving only
|
||||
## http would then leave the new server unable to bind ws. Both suggestions are
|
||||
## routed through `suggest_free_port` so they clear Windows' winnat reservation
|
||||
## table (no point suggesting a port that 10013s on bind). Only the http port
|
||||
## reaches client configs; the ws port is server↔plugin, hence the wording.
|
||||
## The per-client reconfigure steps live behind the crash panel's docs link.
|
||||
static func _free_port_hint(port: int) -> String:
|
||||
var free_http := ClientConfigurator.suggest_free_port(port + 1)
|
||||
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1)
|
||||
return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws]
|
||||
|
||||
|
||||
## URL for the port-conflict guide, pinned to the release tag that matches the
|
||||
## installed plugin version (releases are tagged `v<version>`). The crash-panel
|
||||
## button only exists in builds that ship `docs/port-conflicts.md`, so the
|
||||
## versioned ref always resolves — and a shipped build never points users at a
|
||||
## tip-of-main guide that has drifted from its own UI.
|
||||
static func _port_conflict_docs_url() -> String:
|
||||
var version := ClientConfigurator.get_plugin_version()
|
||||
var git_ref := ("v%s" % version) if not version.is_empty() else "main"
|
||||
return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
|
||||
|
||||
|
||||
## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner`
|
||||
## confirms `*.update_backup` files exist in the addons tree. Mirrors the
|
||||
## issue #354 fix shape: structured, agent-readable diagnostic that survives
|
||||
@@ -981,10 +1133,17 @@ func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void:
|
||||
|
||||
|
||||
## Signal handler for the extracted LogViewer — the panel owns its own
|
||||
## display visibility, the dock owns dispatcher logging routing.
|
||||
## display visibility, the dock owns logging routing. Routes to BOTH the
|
||||
## dispatcher (gates [recv]/[send] recording) and the log buffer's console
|
||||
## echo — the connection logs [event]/[defer] lines directly to the buffer,
|
||||
## bypassing the dispatcher, so gating only `mcp_logging` left the console
|
||||
## spamming with the toggle off (#626). Ring recording is unaffected, so
|
||||
## the dock's log panel keeps working while the console stays quiet.
|
||||
func _on_log_logging_enabled_changed(enabled: bool) -> void:
|
||||
if _connection and _connection.dispatcher:
|
||||
_connection.dispatcher.mcp_logging = enabled
|
||||
if _log_buffer != null:
|
||||
_log_buffer.enabled = enabled
|
||||
|
||||
|
||||
## Signal handler for the extracted PortPickerPanel — the panel range-validates
|
||||
@@ -1297,7 +1456,9 @@ func _refresh_setup_status() -> void:
|
||||
# User mode — check for uv
|
||||
var uv_version := ClientConfigurator.check_uv_version()
|
||||
if not uv_version.is_empty():
|
||||
_setup_container.add_child(_make_status_row("uv", uv_version, Color.GREEN))
|
||||
var compact_uv_version := _compact_uv_version_text(uv_version)
|
||||
var uv_tooltip := uv_version if compact_uv_version != uv_version else ""
|
||||
_setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip))
|
||||
## Build the Server row with a placeholder label we can update every
|
||||
## frame. `_refresh_server_version_label` replaces the text + color
|
||||
## once `McpConnection.server_version` lands via `handshake_ack`, and
|
||||
@@ -1358,19 +1519,39 @@ func _resolve_plugin_symlink_target() -> String:
|
||||
return target
|
||||
|
||||
|
||||
func _make_status_row(label_text: String, value_text: String, value_color: Color) -> HBoxContainer:
|
||||
static func _compact_uv_version_text(uv_version: String) -> String:
|
||||
var text := uv_version.strip_edges()
|
||||
if text.ends_with(")"):
|
||||
var metadata_start := text.rfind(" (")
|
||||
if metadata_start >= 0:
|
||||
return text.substr(0, metadata_start).strip_edges()
|
||||
return text
|
||||
|
||||
|
||||
func _make_status_row(
|
||||
label_text: String,
|
||||
value_text: String,
|
||||
value_color: Color,
|
||||
tooltip_text: String = ""
|
||||
) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
if not tooltip_text.is_empty():
|
||||
row.tooltip_text = tooltip_text
|
||||
|
||||
var label := Label.new()
|
||||
label.text = label_text
|
||||
label.add_theme_color_override("font_color", COLOR_MUTED)
|
||||
label.custom_minimum_size.x = 60
|
||||
if not tooltip_text.is_empty():
|
||||
label.tooltip_text = tooltip_text
|
||||
row.add_child(label)
|
||||
|
||||
var value := Label.new()
|
||||
value.text = value_text
|
||||
value.add_theme_color_override("font_color", value_color)
|
||||
if not tooltip_text.is_empty():
|
||||
value.tooltip_text = tooltip_text
|
||||
row.add_child(value)
|
||||
|
||||
return row
|
||||
@@ -1480,6 +1661,14 @@ func _update_dev_section_buttons() -> void:
|
||||
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
|
||||
|
||||
|
||||
func _client_status_refresh_has_completed() -> bool:
|
||||
return _last_client_status_refresh_completed_msec > 0
|
||||
|
||||
|
||||
func _connected_status_text() -> String:
|
||||
return "Server connected"
|
||||
|
||||
|
||||
func _on_install_uv() -> void:
|
||||
match OS.get_name():
|
||||
"Windows":
|
||||
@@ -1548,28 +1737,65 @@ func _dispatch_client_action(client_id: String, action: String) -> void:
|
||||
_client_action_generations[client_id] = generation
|
||||
var thread := Thread.new()
|
||||
_client_action_threads[client_id] = thread
|
||||
_client_action_started_msec[client_id] = Time.get_ticks_msec()
|
||||
_client_action_names[client_id] = action
|
||||
var err := thread.start(
|
||||
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
|
||||
)
|
||||
if err != OK:
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
_finalize_action_buttons(client_id)
|
||||
_apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread")
|
||||
_refresh_clients_summary()
|
||||
|
||||
|
||||
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void:
|
||||
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> Dictionary:
|
||||
var result: Dictionary
|
||||
if action == "remove":
|
||||
result = ClientConfigurator.remove(client_id, server_url)
|
||||
else:
|
||||
result = ClientConfigurator.configure(client_id, server_url)
|
||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
call_deferred("_apply_client_action_result", client_id, action, result, generation)
|
||||
return {
|
||||
"client_id": client_id,
|
||||
"action": action,
|
||||
"result": result,
|
||||
"generation": generation,
|
||||
}
|
||||
|
||||
|
||||
func _poll_completed_client_action_threads() -> void:
|
||||
for client_id in _client_action_threads.keys():
|
||||
var thread: Thread = _client_action_threads[client_id]
|
||||
if thread == null or thread.is_alive():
|
||||
continue
|
||||
var payload: Variant = thread.wait_to_finish()
|
||||
_client_action_threads[client_id] = null
|
||||
if payload is Dictionary:
|
||||
var data := payload as Dictionary
|
||||
var result: Dictionary = data.get("result", {})
|
||||
_apply_client_action_result(
|
||||
String(data.get("client_id", client_id)),
|
||||
String(data.get("action", _client_action_names.get(client_id, "configure"))),
|
||||
result,
|
||||
int(data.get("generation", _client_action_generations.get(client_id, 0)))
|
||||
)
|
||||
else:
|
||||
_apply_client_action_result(
|
||||
String(client_id),
|
||||
String(_client_action_names.get(client_id, "configure")),
|
||||
{"status": "error", "message": "worker returned no result"},
|
||||
int(_client_action_generations.get(client_id, 0))
|
||||
)
|
||||
|
||||
|
||||
func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void:
|
||||
if int(_client_action_generations.get(client_id, 0)) != generation:
|
||||
if _client_action_threads.get(client_id, null) == null:
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
return
|
||||
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
return
|
||||
@@ -1577,7 +1803,9 @@ func _apply_client_action_result(client_id: String, action: String, result: Dict
|
||||
var t: Thread = _client_action_threads[client_id]
|
||||
if t != null:
|
||||
t.wait_to_finish()
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
_finalize_action_buttons(client_id)
|
||||
if _server_blocks_client_health():
|
||||
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
|
||||
@@ -1645,7 +1873,7 @@ func _on_configure_all_clients() -> void:
|
||||
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
||||
_refresh_clients_summary()
|
||||
return
|
||||
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
|
||||
if ClientRefreshStateScript.should_disable_client_actions(_refresh_state):
|
||||
return
|
||||
for client_id in _client_rows:
|
||||
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
||||
@@ -1700,7 +1928,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
|
||||
var tools_tab := VBoxContainer.new()
|
||||
tools_tab.add_theme_constant_override("separation", 8)
|
||||
var tools_margin := _build_margin_container()
|
||||
tools_margin.name = "Settings"
|
||||
tools_margin.name = "Tools"
|
||||
tools_margin.add_child(tools_tab)
|
||||
tabs.add_child(tools_margin)
|
||||
|
||||
@@ -1793,7 +2021,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
|
||||
footer.add_theme_constant_override("separation", 8)
|
||||
|
||||
_tools_apply_btn = Button.new()
|
||||
_tools_apply_btn.text = "Apply && Restart Server"
|
||||
_tools_apply_btn.text = "Apply and Restart Server"
|
||||
_tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains."
|
||||
_tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_tools_apply_btn.pressed.connect(_on_tools_apply)
|
||||
@@ -1976,8 +2204,11 @@ func _refresh_clients_summary() -> void:
|
||||
)
|
||||
_clients_summary_label.text = text
|
||||
if _client_configure_all_btn != null:
|
||||
_client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state)
|
||||
_client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state)
|
||||
if _client_empty_cta_btn != null:
|
||||
_client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
|
||||
_refresh_drift_banner(mismatched_ids)
|
||||
_update_status()
|
||||
|
||||
|
||||
func _show_manual_command_for(client_id: String) -> void:
|
||||
@@ -2167,8 +2398,8 @@ func _warm_strategy_bytecode() -> void:
|
||||
|
||||
func _begin_client_status_refresh_run() -> int:
|
||||
## Marks a refresh as starting and returns the new generation token.
|
||||
## Generation is bumped here (not at completion) so that a worker callback
|
||||
## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree`
|
||||
## Generation is bumped here (not at completion) so that a worker result
|
||||
## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree`
|
||||
## fires can be detected as stale via generation mismatch.
|
||||
_refresh_state = ClientRefreshStateScript.RUNNING
|
||||
_client_status_refresh_pending = false
|
||||
@@ -2181,7 +2412,7 @@ func _begin_client_status_refresh_run() -> int:
|
||||
|
||||
func _finalize_completed_refresh() -> void:
|
||||
## Stamps cooldown and clears in-flight state. Called at the end of every
|
||||
## refresh that successfully applied results — the worker callback path
|
||||
## refresh that successfully applied results — the worker reaping path
|
||||
## and the no-CLI fast path in `_perform_initial_client_status_refresh`.
|
||||
_last_client_status_refresh_completed_msec = Time.get_ticks_msec()
|
||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
@@ -2308,7 +2539,7 @@ func _retry_deferred_client_status_refresh() -> void:
|
||||
_request_client_status_refresh(force)
|
||||
|
||||
|
||||
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void:
|
||||
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> Dictionary:
|
||||
var results: Dictionary = {}
|
||||
for probe in client_probes:
|
||||
var client_id := String(probe.get("id", ""))
|
||||
@@ -2325,8 +2556,25 @@ func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_
|
||||
"installed": installed,
|
||||
"error_msg": details.get("error_msg", ""),
|
||||
}
|
||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
call_deferred("_apply_client_status_refresh_results", results, generation)
|
||||
return {"results": results, "generation": generation}
|
||||
|
||||
|
||||
func _poll_completed_client_status_refresh_thread() -> void:
|
||||
if _client_status_refresh_thread == null:
|
||||
return
|
||||
if _client_status_refresh_thread.is_alive():
|
||||
return
|
||||
var payload: Variant = _client_status_refresh_thread.wait_to_finish()
|
||||
_client_status_refresh_thread = null
|
||||
if payload is Dictionary:
|
||||
var data := payload as Dictionary
|
||||
var results: Dictionary = data.get("results", {})
|
||||
_apply_client_status_refresh_results(
|
||||
results,
|
||||
int(data.get("generation", _client_status_refresh_generation))
|
||||
)
|
||||
else:
|
||||
_apply_client_status_refresh_results({}, _client_status_refresh_generation)
|
||||
|
||||
|
||||
func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void:
|
||||
@@ -2491,6 +2739,5 @@ func _on_install_state_changed(state: Dictionary) -> void:
|
||||
if state.has("banner_visible") and _update_banner != null:
|
||||
_update_banner.visible = bool(state["banner_visible"])
|
||||
if String(state.get("outcome", "")) == "success" and _update_label != null:
|
||||
## Visual confirmation for the pre-4.4 "Updated! Restart the editor."
|
||||
## terminal state — the only outcome the manager paints green for.
|
||||
## Visual confirmation for successful terminal update states.
|
||||
_update_label.add_theme_color_override("font_color", Color.GREEN)
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="Godot AI"
|
||||
description="MCP server and AI tools for Godot"
|
||||
author="Godot AI"
|
||||
version="2.7.6"
|
||||
version="2.9.1"
|
||||
script="plugin.gd"
|
||||
|
||||
+75
-49
@@ -6,13 +6,8 @@ const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd
|
||||
|
||||
## Editor-process Logger subclass — captures parse errors, @tool runtime
|
||||
## errors, and push_error/push_warning so the LLM can read them via
|
||||
## `logs_read(source="editor")`. Loaded dynamically because
|
||||
## `extends Logger` requires Godot 4.5+. The logger script lives in the
|
||||
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor scan never
|
||||
## parses it (no "Could not find base class Logger" error on < 4.5), and
|
||||
## LoggerLoader compiles it from source at runtime only after the
|
||||
## ClassDB.class_exists("Logger") gate below. See issue #231 / #475.
|
||||
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
|
||||
## `logs_read(source="editor")`.
|
||||
const EditorLogger := preload("res://addons/godot_ai/runtime/editor_logger.gd")
|
||||
|
||||
## EditorSettings keys used to remember which server process the plugin
|
||||
## spawned — survives editor restarts, lets a later editor session adopt
|
||||
@@ -48,6 +43,7 @@ const Telemetry := preload("res://addons/godot_ai/telemetry.gd")
|
||||
const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd")
|
||||
const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd")
|
||||
const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd")
|
||||
const SurfacedErrorTracker := preload("res://addons/godot_ai/utils/surfaced_error_tracker.gd")
|
||||
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
|
||||
const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd")
|
||||
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
||||
@@ -83,6 +79,8 @@ const EnvironmentHandler := preload("res://addons/godot_ai/handlers/environment_
|
||||
const TextureHandler := preload("res://addons/godot_ai/handlers/texture_handler.gd")
|
||||
const CurveHandler := preload("res://addons/godot_ai/handlers/curve_handler.gd")
|
||||
const ControlDrawRecipeHandler := preload("res://addons/godot_ai/handlers/control_draw_recipe_handler.gd")
|
||||
const TilemapHandler := preload("res://addons/godot_ai/handlers/tilemap_handler.gd")
|
||||
const TilesetHandler := preload("res://addons/godot_ai/handlers/tileset_handler.gd")
|
||||
|
||||
## The Python server writes its own PID here on startup (passed as
|
||||
## `--pid-file`) and unlinks on clean exit. Deterministic replacement
|
||||
@@ -144,19 +142,14 @@ const STARTUP_TRACE_COUNTER_NAMES := [
|
||||
##
|
||||
## `tests/unit/test_plugin_self_update_safety.py` locks this wording in.
|
||||
##
|
||||
## `_editor_logger` is untyped because its script extends Godot 4.5+'s Logger
|
||||
## class: `logger_loader.gd` compiles it at runtime from on-disk source
|
||||
## (FileAccess + `GDScript.new()`) past the `ClassDB.class_exists("Logger")`
|
||||
## gate in `_attach_editor_logger`, so the plugin still parses on 4.4. Null on
|
||||
## Godot < 4.5 or before `_attach_editor_logger` runs; "attached" state IS
|
||||
## exactly "non-null".
|
||||
var _connection
|
||||
var _dispatcher
|
||||
var _telemetry
|
||||
var _log_buffer
|
||||
var _game_log_buffer
|
||||
var _editor_log_buffer
|
||||
var _editor_logger
|
||||
var _surfaced_error_tracker
|
||||
var _editor_logger: Logger
|
||||
var _dock
|
||||
var _handlers: Array = [] # prevent GC of RefCounted handlers
|
||||
var _debugger_plugin
|
||||
@@ -197,11 +190,9 @@ func _enter_tree() -> void:
|
||||
print("MCP | plugin disabled in headless mode")
|
||||
return
|
||||
|
||||
## Self-update from a pre-loggers/ version leaves the old logger scripts
|
||||
## orphaned at runtime/*.gd (the runner only writes files in the new ZIP,
|
||||
## it doesn't prune). Those still `extends Logger` and re-emit the parse
|
||||
## errors on Godot < 4.5. Delete them once so upgraders match a fresh
|
||||
## install. No-op on fresh installs and dev checkouts (files absent).
|
||||
## Self-update extracts over the live addon and doesn't prune files that
|
||||
## disappeared from the new ZIP. Remove obsolete Logger-loader quarantine
|
||||
## files/folders once so upgraders match a fresh install.
|
||||
_cleanup_legacy_logger_scripts()
|
||||
|
||||
## Register port overrides before spawn so `http_port()` / `ws_port()`
|
||||
@@ -211,17 +202,24 @@ func _enter_tree() -> void:
|
||||
_startup_trace_phase("settings_registered")
|
||||
|
||||
_log_buffer = LogBuffer.new()
|
||||
## Apply the persisted dock "Log" toggle before anything logs through the
|
||||
## buffer. Without this the choice only took effect after a manual toggle
|
||||
## and reset to noisy on every editor restart (#626).
|
||||
_log_buffer.enabled = McpSettings.mcp_logging_enabled()
|
||||
_start_server()
|
||||
_startup_trace_phase("server_start")
|
||||
|
||||
_game_log_buffer = GameLogBuffer.new()
|
||||
_editor_log_buffer = EditorLogBuffer.new()
|
||||
_surfaced_error_tracker = SurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer)
|
||||
_attach_editor_logger()
|
||||
_dispatcher = Dispatcher.new(_log_buffer)
|
||||
_dispatcher = Dispatcher.new(_log_buffer, _surfaced_error_tracker)
|
||||
_dispatcher.mcp_logging = _log_buffer.enabled
|
||||
_startup_trace_phase("core_objects")
|
||||
|
||||
_connection = Connection.new()
|
||||
_connection.log_buffer = _log_buffer
|
||||
_connection.surfaced_error_tracker = _surfaced_error_tracker
|
||||
_connection.ws_port = _resolved_ws_port
|
||||
_connection.connect_blocked = _lifecycle.is_connection_blocked()
|
||||
_connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "")
|
||||
@@ -233,19 +231,20 @@ func _enter_tree() -> void:
|
||||
|
||||
_telemetry = Telemetry.new(_connection)
|
||||
|
||||
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer)
|
||||
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer, _surfaced_error_tracker)
|
||||
add_debugger_plugin(_debugger_plugin)
|
||||
_connection.debugger_plugin = _debugger_plugin
|
||||
_ensure_game_helper_autoload()
|
||||
|
||||
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer)
|
||||
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer, null, _surfaced_error_tracker)
|
||||
var scene_handler := SceneHandler.new(_connection)
|
||||
var node_handler := NodeHandler.new(get_undo_redo())
|
||||
var project_handler := ProjectHandler.new(_connection, _debugger_plugin)
|
||||
var project_handler := ProjectHandler.new(_connection, _debugger_plugin, _editor_log_buffer)
|
||||
var client_handler := ClientHandler.new()
|
||||
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
|
||||
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
|
||||
var api_handler := ApiHandler.new()
|
||||
var filesystem_handler := FilesystemHandler.new()
|
||||
var filesystem_handler := FilesystemHandler.new(_connection)
|
||||
var signal_handler := SignalHandler.new(get_undo_redo())
|
||||
var autoload_handler := AutoloadHandler.new()
|
||||
var input_handler := InputHandler.new()
|
||||
@@ -263,7 +262,9 @@ func _enter_tree() -> void:
|
||||
var texture_handler := TextureHandler.new(get_undo_redo(), _connection)
|
||||
var curve_handler := CurveHandler.new(get_undo_redo(), _connection)
|
||||
var control_draw_recipe_handler := ControlDrawRecipeHandler.new(get_undo_redo())
|
||||
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler]
|
||||
var tilemap_handler := TilemapHandler.new(get_undo_redo())
|
||||
var tileset_handler := TilesetHandler.new()
|
||||
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler, tilemap_handler, tileset_handler]
|
||||
|
||||
_dispatcher.register("get_editor_state", editor_handler.get_editor_state)
|
||||
_dispatcher.register("get_scene_tree", scene_handler.get_scene_tree)
|
||||
@@ -318,6 +319,7 @@ func _enter_tree() -> void:
|
||||
_dispatcher.register("read_file", filesystem_handler.read_file)
|
||||
_dispatcher.register("write_file", filesystem_handler.write_file)
|
||||
_dispatcher.register("reimport", filesystem_handler.reimport)
|
||||
_dispatcher.register("scan_filesystem", filesystem_handler.scan_filesystem)
|
||||
_dispatcher.register("list_signals", signal_handler.list_signals)
|
||||
_dispatcher.register("connect_signal", signal_handler.connect_signal)
|
||||
_dispatcher.register("disconnect_signal", signal_handler.disconnect_signal)
|
||||
@@ -326,8 +328,10 @@ func _enter_tree() -> void:
|
||||
_dispatcher.register("remove_autoload", autoload_handler.remove_autoload)
|
||||
_dispatcher.register("list_actions", input_handler.list_actions)
|
||||
_dispatcher.register("add_action", input_handler.add_action)
|
||||
_dispatcher.register("ensure_action", input_handler.ensure_action)
|
||||
_dispatcher.register("remove_action", input_handler.remove_action)
|
||||
_dispatcher.register("bind_event", input_handler.bind_event)
|
||||
_dispatcher.register("ensure_binding", input_handler.ensure_binding)
|
||||
_dispatcher.register("run_tests", test_handler.run_tests)
|
||||
_dispatcher.register("get_test_results", test_handler.get_test_results)
|
||||
_dispatcher.register("batch_execute", batch_handler.batch_execute)
|
||||
@@ -393,6 +397,12 @@ func _enter_tree() -> void:
|
||||
_dispatcher.register(
|
||||
"control_draw_recipe", control_draw_recipe_handler.control_draw_recipe
|
||||
)
|
||||
_dispatcher.register("tilemap_set_cell", tilemap_handler.set_cell)
|
||||
_dispatcher.register("tilemap_set_cells_rect", tilemap_handler.set_cells_rect)
|
||||
_dispatcher.register("tilemap_clear", tilemap_handler.clear_layer)
|
||||
_dispatcher.register("tilemap_get_cells", tilemap_handler.get_used_cells)
|
||||
_dispatcher.register("tileset_get_atlas_tiles", tileset_handler.get_atlas_tiles)
|
||||
_dispatcher.register("tileset_get_atlas_image", tileset_handler.get_atlas_image)
|
||||
|
||||
_connection.dispatcher = _dispatcher
|
||||
add_child(_connection)
|
||||
@@ -489,6 +499,7 @@ func _exit_tree() -> void:
|
||||
_log_buffer = null
|
||||
_game_log_buffer = null
|
||||
_editor_log_buffer = null
|
||||
_surfaced_error_tracker = null
|
||||
|
||||
_stop_server()
|
||||
## Symmetric with prepare_for_update_reload: the static guard persists
|
||||
@@ -504,9 +515,7 @@ func _exit_tree() -> void:
|
||||
## Attach editor_logger.gd as a Godot logger so editor-process script
|
||||
## errors (parse errors, @tool runtime errors, EditorPlugin errors,
|
||||
## push_error/push_warning) flow into _editor_log_buffer for
|
||||
## logs_read(source="editor"). Logger subclassing is 4.5+ only; the
|
||||
## ClassDB gate keeps the plugin loadable on 4.4 with no-op editor logs
|
||||
## (the buffer stays empty, logs_read returns no entries).
|
||||
## logs_read(source="editor").
|
||||
##
|
||||
## Limitation called out in the issue: parse errors fired *before* the
|
||||
## plugin's _enter_tree (e.g. during the editor's initial filesystem
|
||||
@@ -516,36 +525,53 @@ func _exit_tree() -> void:
|
||||
## file would re-emit them but at the cost of disrupting the user's
|
||||
## editing state, so we accept the gap.
|
||||
func _attach_editor_logger() -> void:
|
||||
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger")):
|
||||
return
|
||||
var logger_script := LoggerLoader.build(LoggerLoader.EDITOR_LOGGER_PATH)
|
||||
if logger_script == null:
|
||||
return
|
||||
_editor_logger = logger_script.new(_editor_log_buffer)
|
||||
OS.call("add_logger", _editor_logger)
|
||||
_editor_logger = EditorLogger.new(_editor_log_buffer)
|
||||
OS.add_logger(_editor_logger)
|
||||
|
||||
|
||||
## Remove the pre-2.5.8 logger scripts left at runtime/*.gd by a self-update
|
||||
## (the runner doesn't prune files dropped between versions). They `extends
|
||||
## Logger` and would re-emit "Could not find base class Logger" parse errors
|
||||
## on Godot < 4.5 even though the live copies now live in the .gdignore'd
|
||||
## runtime/loggers/ folder. Idempotent: existence-guarded, so it's a no-op on
|
||||
## fresh installs and symlinked dev checkouts.
|
||||
## Remove old Logger-quarantine artifacts left by extract-over-live
|
||||
## self-update. Idempotent: existence-guarded, so it's a no-op on fresh
|
||||
## installs and symlinked dev checkouts.
|
||||
func _cleanup_legacy_logger_scripts() -> void:
|
||||
var legacy := [
|
||||
"res://addons/godot_ai/runtime/editor_logger.gd",
|
||||
"res://addons/godot_ai/runtime/editor_logger.gd.uid",
|
||||
"res://addons/godot_ai/runtime/game_logger.gd",
|
||||
"res://addons/godot_ai/runtime/game_logger.gd.uid",
|
||||
var legacy_files := [
|
||||
"res://addons/godot_ai/runtime/logger_loader.gd",
|
||||
"res://addons/godot_ai/runtime/logger_loader.gd.uid",
|
||||
"res://addons/godot_ai/testing/script_error_capture_loader.gd",
|
||||
"res://addons/godot_ai/testing/script_error_capture_loader.gd.uid",
|
||||
]
|
||||
for res_path in legacy:
|
||||
for res_path in legacy_files:
|
||||
if FileAccess.file_exists(res_path):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path))
|
||||
var legacy_dirs := [
|
||||
"res://addons/godot_ai/runtime/loggers",
|
||||
"res://addons/godot_ai/testing/loggers",
|
||||
]
|
||||
for res_path in legacy_dirs:
|
||||
var absolute := ProjectSettings.globalize_path(res_path)
|
||||
if DirAccess.dir_exists_absolute(absolute):
|
||||
_remove_dir_recursive_absolute(absolute)
|
||||
|
||||
|
||||
static func _remove_dir_recursive_absolute(path: String) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while not name.is_empty():
|
||||
var child := path.path_join(name)
|
||||
if dir.current_is_dir():
|
||||
_remove_dir_recursive_absolute(child)
|
||||
else:
|
||||
DirAccess.remove_absolute(child)
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
DirAccess.remove_absolute(path)
|
||||
|
||||
|
||||
func _detach_editor_logger() -> void:
|
||||
if _editor_logger != null and OS.has_method("remove_logger"):
|
||||
OS.call("remove_logger", _editor_logger)
|
||||
if _editor_logger != null:
|
||||
OS.remove_logger(_editor_logger)
|
||||
_editor_logger = null
|
||||
|
||||
|
||||
|
||||
+7
-19
@@ -3,20 +3,12 @@ extends Logger
|
||||
|
||||
## Editor-process Logger subclass.
|
||||
##
|
||||
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
|
||||
## class which Godot only exposes from 4.5+. This file lives in the
|
||||
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
|
||||
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
|
||||
## "Could not find base class Logger" error (it used to, before #475's
|
||||
## follow-up). plugin.gd builds it from source at runtime via
|
||||
## `logger_loader.gd` and only calls OS.add_logger() after gating on
|
||||
## ClassDB.class_exists("Logger"), so the `extends Logger` parse only ever
|
||||
## happens on 4.5+ where it resolves. Registered from plugin.gd::_enter_tree
|
||||
## NOTE: deliberately no `class_name`. Registered from plugin.gd::_enter_tree
|
||||
## so we can intercept editor-process script errors — parse errors, @tool
|
||||
## runtime errors, EditorPlugin errors, push_error/push_warning — and
|
||||
## surface them via `logs_read(source="editor")`. Without this, the LLM
|
||||
## sees nothing in `logs_read` while the same errors show in red lines in
|
||||
## Godot's Output panel.
|
||||
## runtime errors, EditorPlugin errors, push_error/push_warning — and surface
|
||||
## them via `logs_read(source="editor")`. Without this, the LLM sees nothing
|
||||
## in `logs_read` while the same errors show in red lines in Godot's Output
|
||||
## panel.
|
||||
##
|
||||
## Why only `_log_error` and not `_log_message`:
|
||||
## `_log_message(msg, error)` covers print() and printerr(), which is the
|
||||
@@ -34,17 +26,13 @@ extends Logger
|
||||
const ADDON_PATH_MARKER := "/addons/godot_ai/"
|
||||
|
||||
## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name.
|
||||
## This script is compiled from source at runtime by logger_loader.gd; a bare
|
||||
## class_name reference depends on the global class-name table being populated
|
||||
## A bare class_name reference depends on the global class-name table being populated
|
||||
## at compile time, which isn't guaranteed on a cold editor enable mid-scan.
|
||||
## `const preload` resolves at compile time independent of the registry —
|
||||
## matches game_logger.gd's deliberate choice for the same reason.
|
||||
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
|
||||
|
||||
## McpEditorLogBuffer — untyped because this script is loaded dynamically and
|
||||
## McpEditorLogBuffer's class_name isn't yet registered on the parser at the
|
||||
## time `extends Logger` resolves. Constructor-injected so the hot path
|
||||
## doesn't need a per-call null check.
|
||||
## Constructor-injected so the hot path doesn't need a per-call null check.
|
||||
var _buffer
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxfregddqj5b8
|
||||
@@ -23,13 +23,25 @@ const CAPTURE_PREFIX := "mcp"
|
||||
## Cap per-frame flush so a runaway print loop can't blow the debugger's
|
||||
## packet budget in a single send. Surplus stays queued for the next frame.
|
||||
const FLUSH_BATCH_LIMIT := 200
|
||||
## How long take_screenshot waits for the game's first real presentation
|
||||
## before reading the viewport texture back. The "mcp" capture registers in
|
||||
## this autoload's _ready(), which runs BEFORE the main scene enters the tree
|
||||
## and before the renderer has presented anything — so a request arriving
|
||||
## right after mcp:hello would otherwise read back the clear-color
|
||||
## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's
|
||||
## GPU-less paravirtualized macOS runners, where the first present lags
|
||||
## seconds behind boot). MUST stay below the editor-side reply timer
|
||||
## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a
|
||||
## game that genuinely can't render falls through to the existing
|
||||
## texture/image error replies before the editor gives up with its
|
||||
## generic timeout.
|
||||
const FIRST_FRAME_WAIT_SEC := 6.0
|
||||
|
||||
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
|
||||
const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd")
|
||||
|
||||
var _registered := false
|
||||
## Untyped because the McpGameLogger script is loaded dynamically (it
|
||||
## extends Logger, which only exists in Godot 4.5+).
|
||||
var _logger
|
||||
## Captures game-process print, warning, and error output for the editor.
|
||||
var _logger: Logger
|
||||
var _logger_attached := false
|
||||
## Entries drained from the logger but not yet sent over the debugger
|
||||
## channel. Holds the tail of one drain() so we can bleed it out across
|
||||
@@ -64,17 +76,10 @@ func _ready() -> void:
|
||||
_registered = true
|
||||
## Capture print() / printerr() / push_error() / push_warning() and
|
||||
## ferry them to the editor in mcp:log_batch messages flushed from
|
||||
## _process. Logger subclassing was added in Godot 4.5 — gate on
|
||||
## ClassDB so the rest of the helper still loads on older engines.
|
||||
## game_logger.gd lives in the `.gdignore`'d runtime/loggers/ folder so
|
||||
## it never parse-errors during a < 4.5 editor scan; LoggerLoader
|
||||
## compiles it from source at runtime, only past this gate.
|
||||
if ClassDB.class_exists("Logger") and OS.has_method("add_logger"):
|
||||
var logger_script := LoggerLoader.build(LoggerLoader.GAME_LOGGER_PATH)
|
||||
if logger_script != null:
|
||||
_logger = logger_script.new()
|
||||
OS.call("add_logger", _logger)
|
||||
_logger_attached = true
|
||||
## _process.
|
||||
_logger = GameLogger.new()
|
||||
OS.add_logger(_logger)
|
||||
_logger_attached = true
|
||||
## Routed to the editor's Output panel via Godot's remote-stdout
|
||||
## forwarder — handy when diagnosing why capture timed out.
|
||||
print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)"
|
||||
@@ -109,16 +114,16 @@ func _exit_tree() -> void:
|
||||
if _registered:
|
||||
EngineDebugger.unregister_message_capture(CAPTURE_PREFIX)
|
||||
_registered = false
|
||||
if _logger_attached and _logger != null and OS.has_method("remove_logger"):
|
||||
OS.call("remove_logger", _logger)
|
||||
if _logger_attached and _logger != null:
|
||||
OS.remove_logger(_logger)
|
||||
_logger_attached = false
|
||||
_logger = null
|
||||
|
||||
|
||||
## Dispatched for messages prefixed "mcp:" on the debugger channel.
|
||||
## Different Godot versions pass either the tail ("take_screenshot") or the
|
||||
## full message ("mcp:take_screenshot") to the capture callable — accept
|
||||
## both forms so this works across 4.2/4.3/4.4/4.5.
|
||||
## Godot passes the full message ("mcp:take_screenshot") to the capture
|
||||
## callable; trim defensively so tests can still call the helper with either
|
||||
## form.
|
||||
func _on_debug_message(message: String, data: Array) -> bool:
|
||||
var action := message.trim_prefix("mcp:")
|
||||
match action:
|
||||
@@ -141,11 +146,25 @@ func _handle_take_screenshot(data: Array) -> void:
|
||||
var request_id: String = data[0] if data.size() > 0 else ""
|
||||
var max_resolution: int = int(data[1]) if data.size() > 1 else 0
|
||||
|
||||
var viewport := get_tree().root
|
||||
var tree := get_tree()
|
||||
var viewport := tree.root if tree != null else null
|
||||
if viewport == null:
|
||||
_reply_error(request_id, "No game root viewport available")
|
||||
return
|
||||
|
||||
## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in
|
||||
## the tree and at least one frame has been drawn after this request, so
|
||||
## the readback never precedes the first real present. Past the deadline,
|
||||
## fall through anyway: current_scene stays null under a custom main
|
||||
## loop, and frames_drawn never advances in a render-less game — both
|
||||
## are handled by the texture/image error replies below.
|
||||
var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0)
|
||||
while tree.current_scene == null and Time.get_ticks_msec() < deadline:
|
||||
await tree.process_frame
|
||||
var frames_at_request := Engine.get_frames_drawn()
|
||||
while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline:
|
||||
await tree.process_frame
|
||||
|
||||
var texture := viewport.get_texture()
|
||||
if texture == null:
|
||||
_reply_error(request_id, "Root viewport has no texture (headless?)")
|
||||
@@ -217,6 +236,8 @@ func _handle_game_command(data: Array) -> void:
|
||||
result = _game_input_mouse(json.data)
|
||||
"input_gamepad":
|
||||
result = _game_input_gamepad(json.data)
|
||||
"input_action":
|
||||
result = _game_input_action(json.data)
|
||||
"input_state":
|
||||
result = _game_input_state(json.data)
|
||||
_:
|
||||
@@ -458,7 +479,10 @@ func _game_input_key(params: Dictionary) -> Dictionary:
|
||||
|
||||
func _game_input_mouse(params: Dictionary) -> Dictionary:
|
||||
var event := str(params.get("event", "button"))
|
||||
var pos := _dict_to_vector2(params.get("position", {}))
|
||||
var pos_result := _resolve_mouse_position(params.get("position"))
|
||||
if pos_result.has("error"):
|
||||
return {"sent": false, "event": event, "error": pos_result.error}
|
||||
var pos: Vector2 = pos_result.position
|
||||
match event:
|
||||
"motion":
|
||||
var motion := InputEventMouseMotion.new()
|
||||
@@ -504,6 +528,27 @@ func _game_input_gamepad(params: Dictionary) -> Dictionary:
|
||||
return {"sent": false, "error": "Invalid gamepad control: %s" % control}
|
||||
|
||||
|
||||
func _game_input_action(params: Dictionary) -> Dictionary:
|
||||
var action := str(params.get("action", ""))
|
||||
if action.is_empty():
|
||||
return {"sent": false, "error": "Missing action"}
|
||||
if not InputMap.has_action(action):
|
||||
return {"sent": false, "action": action, "error": "Unknown action: %s" % action}
|
||||
var pressed := bool(params.get("pressed", true))
|
||||
var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0)
|
||||
if pressed:
|
||||
Input.action_press(action, strength)
|
||||
else:
|
||||
Input.action_release(action)
|
||||
return {
|
||||
"sent": true,
|
||||
"action": action,
|
||||
"pressed": pressed,
|
||||
"strength": strength,
|
||||
"delivery": "action_state",
|
||||
}
|
||||
|
||||
|
||||
func _game_input_state(params: Dictionary) -> Dictionary:
|
||||
var actions: Array = params.get("actions", [])
|
||||
if actions.is_empty():
|
||||
@@ -515,14 +560,41 @@ func _game_input_state(params: Dictionary) -> Dictionary:
|
||||
return {"actions": states}
|
||||
|
||||
|
||||
func _dict_to_vector2(value: Variant) -> Vector2:
|
||||
## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to
|
||||
## the live cursor position — a deliberate default. A present but wrong-shaped
|
||||
## value is rejected instead of silently substituting the cursor, which
|
||||
## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array;
|
||||
## returns {position: Vector2} or {error: String}.
|
||||
func _resolve_mouse_position(value: Variant) -> Dictionary:
|
||||
var viewport := get_viewport()
|
||||
var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO
|
||||
if value == null:
|
||||
return {"position": fallback}
|
||||
if value is Dictionary:
|
||||
if value.is_empty() or (not value.has("x") and not value.has("y")):
|
||||
return fallback
|
||||
return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y)))
|
||||
return fallback
|
||||
var dict: Dictionary = value
|
||||
if dict.is_empty():
|
||||
return {"position": fallback}
|
||||
# A non-empty dict that carries neither coordinate is a caller mistake,
|
||||
# not "use the default" — reject rather than silently substitute.
|
||||
if not dict.has("x") and not dict.has("y"):
|
||||
return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())}
|
||||
var x_val: Variant = dict.get("x", fallback.x)
|
||||
var y_val: Variant = dict.get("y", fallback.y)
|
||||
if not _is_number(x_val) or not _is_number(y_val):
|
||||
return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]}
|
||||
return {"position": Vector2(float(x_val), float(y_val))}
|
||||
if value is Array:
|
||||
var arr: Array = value
|
||||
if arr.size() != 2:
|
||||
return {"error": "position array must be [x, y] (got %d elements)" % arr.size()}
|
||||
if not _is_number(arr[0]) or not _is_number(arr[1]):
|
||||
return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]}
|
||||
return {"position": Vector2(float(arr[0]), float(arr[1]))}
|
||||
return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))}
|
||||
|
||||
|
||||
func _is_number(v: Variant) -> bool:
|
||||
return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT
|
||||
|
||||
|
||||
func _mouse_button_index(name: String) -> int:
|
||||
|
||||
+6
-13
@@ -3,17 +3,10 @@ extends Logger
|
||||
|
||||
## Game-process Logger subclass.
|
||||
##
|
||||
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
|
||||
## class which Godot only exposes from 4.5+. This file lives in the
|
||||
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
|
||||
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
|
||||
## "Could not find base class Logger" error (it used to, before #475's
|
||||
## follow-up). game_helper.gd builds it from source at runtime via
|
||||
## `logger_loader.gd` and only calls OS.add_logger() after gating on
|
||||
## ClassDB.class_exists("Logger"). Registered from inside the running game
|
||||
## so we can intercept print(), printerr(), push_error(), and
|
||||
## push_warning() and ferry them back to the editor over the
|
||||
## EngineDebugger channel — the same bridge PR #76 uses for screenshots.
|
||||
## NOTE: deliberately no `class_name`. Registered from inside the running
|
||||
## game so we can intercept print(), printerr(), push_error(), and
|
||||
## push_warning() and ferry them back to the editor over the EngineDebugger
|
||||
## channel — the same bridge PR #76 uses for screenshots.
|
||||
##
|
||||
## Logger virtuals can be called from any thread (e.g. async loaders push
|
||||
## errors off the main thread). We accumulate into _pending under a Mutex
|
||||
@@ -81,9 +74,9 @@ func _log_error(
|
||||
## Collect every function name in the first non-empty backtrace so
|
||||
## game_helper can match its eval's uniquely named wrapper function.
|
||||
var funcs := PackedStringArray()
|
||||
for bt in script_backtraces:
|
||||
for bt: RefCounted in script_backtraces:
|
||||
if bt != null and bt.get_frame_count() > 0:
|
||||
for i in bt.get_frame_count():
|
||||
for i: int in bt.get_frame_count():
|
||||
funcs.append(bt.get_frame_function(i))
|
||||
break
|
||||
_mutex.lock()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwcs6d1y7vhqi
|
||||
@@ -1,57 +0,0 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Runtime builder for the `extends Logger` scripts in `runtime/loggers/`.
|
||||
##
|
||||
## `Logger` is a Godot 4.5+ class. A `.gd` file that statically declares
|
||||
## `extends Logger` is rejected by the parser on Godot < 4.5 — and Godot's
|
||||
## editor filesystem scan parses *every* `.gd` under the project, so just
|
||||
## shipping `editor_logger.gd` / `game_logger.gd` printed two
|
||||
## `Parse Error: Could not find base class "Logger"` lines on every 4.3/4.4
|
||||
## editor startup (#475 follow-up). They were functionally harmless (the
|
||||
## scripts are only ever instanced behind a `ClassDB.class_exists("Logger")`
|
||||
## gate) but they were real red error text we shouldn't ship.
|
||||
##
|
||||
## Fix: the two logger scripts live in `runtime/loggers/`, which carries a
|
||||
## `.gdignore` so the editor scan skips the folder entirely — no parse, no
|
||||
## error, on any engine. This loader reads the source off disk with
|
||||
## `FileAccess` (unaffected by `.gdignore`, which only governs the resource
|
||||
## importer) and compiles it at runtime via `GDScript.new()`. Callers gate
|
||||
## on `ClassDB.class_exists("Logger")` first, so `build()` only ever runs on
|
||||
## 4.5+, where `extends Logger` resolves cleanly.
|
||||
##
|
||||
## This script itself does NOT extend Logger, so it parses on every engine
|
||||
## and is safe to `preload` from `plugin.gd` and `game_helper.gd`.
|
||||
|
||||
const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd"
|
||||
const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd"
|
||||
const VALIDATION_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/validation_logger.gd"
|
||||
|
||||
|
||||
## Compile a `.gdignore`'d logger script from its on-disk source. Returns the
|
||||
## ready-to-instance GDScript, or null if the file is missing (e.g. excluded
|
||||
## from an exported game) or fails to compile. Callers must already have
|
||||
## confirmed `ClassDB.class_exists("Logger")` — building an `extends Logger`
|
||||
## script on an engine without the class will fail the reload() and return
|
||||
## null, which the gated callers treat as "logging unavailable".
|
||||
static func build(path: String) -> GDScript:
|
||||
if not FileAccess.file_exists(path):
|
||||
return null
|
||||
var source := FileAccess.get_file_as_string(path)
|
||||
if source.is_empty():
|
||||
return null
|
||||
var script := GDScript.new()
|
||||
script.source_code = source
|
||||
## Deliberately do NOT set `script.resource_path`: this builds a fresh
|
||||
## anonymous GDScript every call, and a reload cycle (editor_reload_plugin,
|
||||
## self-update disable→enable) calls build() again for the same path. Two
|
||||
## live Resources sharing one non-empty resource_path trips Godot's
|
||||
## "Another resource is loaded from path ..." error and leaves the new
|
||||
## script with an empty path anyway — re-introducing red console text on
|
||||
## every reload, the exact thing this folder's .gdignore set out to remove.
|
||||
## game_helper.gd::_handle_eval compiles from source the same way and also
|
||||
## omits resource_path. The script still resolves its absolute preloads /
|
||||
## class_names fine without a path.
|
||||
if script.reload() != OK:
|
||||
return null
|
||||
return script
|
||||
@@ -1 +0,0 @@
|
||||
uid://d3plpedkpvec6
|
||||
@@ -0,0 +1 @@
|
||||
uid://b2ff3aot6t2l4
|
||||
+1
-3
@@ -3,9 +3,7 @@ extends Logger
|
||||
|
||||
## Captures GDScript runtime errors emitted while a test is running.
|
||||
##
|
||||
## Deliberately no class_name: this script is compiled dynamically by
|
||||
## script_error_capture_loader.gd only when Logger exists. It lives in a
|
||||
## `.gdignore`d folder so older Godot editor scans never parse `extends Logger`.
|
||||
## Deliberately no class_name: this is an internal test helper.
|
||||
##
|
||||
## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
|
||||
## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
|
||||
@@ -0,0 +1 @@
|
||||
uid://crk8w2nei087v
|
||||
@@ -1,26 +0,0 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## Builds the Logger-based test script-error capture lazily.
|
||||
##
|
||||
## Logger exists only on newer Godot versions. The capture implementation lives
|
||||
## under a `.gdignore`d folder and is compiled from source only after the runner
|
||||
## verifies the Logger API is present, so older editor scans do not parse an
|
||||
## `extends Logger` file and emit red startup errors.
|
||||
|
||||
const SCRIPT_ERROR_CAPTURE_PATH := "res://addons/godot_ai/testing/loggers/script_error_capture.gd"
|
||||
|
||||
|
||||
static func build() -> Object:
|
||||
if not ClassDB.class_exists("Logger") or not OS.has_method("add_logger"):
|
||||
return null
|
||||
if not FileAccess.file_exists(SCRIPT_ERROR_CAPTURE_PATH):
|
||||
return null
|
||||
var source := FileAccess.get_file_as_string(SCRIPT_ERROR_CAPTURE_PATH)
|
||||
if source.is_empty():
|
||||
return null
|
||||
var script := GDScript.new()
|
||||
script.source_code = source
|
||||
if script.reload() != OK:
|
||||
return null
|
||||
return script.new()
|
||||
@@ -1 +0,0 @@
|
||||
uid://pmtc07go8ty4
|
||||
@@ -5,17 +5,17 @@ extends RefCounted
|
||||
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
|
||||
## on McpTestSuite instances, runs them, and collects structured results.
|
||||
|
||||
const ScriptErrorCaptureLoader := preload("res://addons/godot_ai/testing/script_error_capture_loader.gd")
|
||||
const ScriptErrorCapture := preload("res://addons/godot_ai/testing/script_error_capture.gd")
|
||||
|
||||
var _results: Array[Dictionary] = []
|
||||
var _last_run_ms: int = 0
|
||||
var _script_error_capture: Object = null
|
||||
var _script_error_capture: ScriptErrorCapture = null
|
||||
var _capture_registered := false
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
|
||||
OS.call("remove_logger", _script_error_capture)
|
||||
OS.remove_logger(_script_error_capture)
|
||||
_capture_registered = false
|
||||
|
||||
|
||||
@@ -173,10 +173,10 @@ func _register_capture() -> void:
|
||||
if _capture_registered:
|
||||
return
|
||||
if _script_error_capture == null:
|
||||
_script_error_capture = ScriptErrorCaptureLoader.build()
|
||||
_script_error_capture = ScriptErrorCapture.new()
|
||||
if _script_error_capture == null:
|
||||
return
|
||||
OS.call("add_logger", _script_error_capture)
|
||||
OS.add_logger(_script_error_capture)
|
||||
_capture_registered = true
|
||||
|
||||
|
||||
@@ -186,19 +186,19 @@ func _unregister_capture() -> void:
|
||||
if _script_error_capture == null:
|
||||
_capture_registered = false
|
||||
return
|
||||
OS.call("remove_logger", _script_error_capture)
|
||||
OS.remove_logger(_script_error_capture)
|
||||
_capture_registered = false
|
||||
|
||||
|
||||
func _begin_script_error_capture() -> void:
|
||||
if _script_error_capture != null and _capture_registered:
|
||||
_script_error_capture.call("begin_capture")
|
||||
_script_error_capture.begin_capture()
|
||||
|
||||
|
||||
func _end_script_error_capture() -> PackedStringArray:
|
||||
if _script_error_capture == null or not _capture_registered:
|
||||
return PackedStringArray()
|
||||
return _script_error_capture.call("end_capture") as PackedStringArray
|
||||
return _script_error_capture.end_capture()
|
||||
|
||||
|
||||
static func _edited_scene_root() -> Node:
|
||||
|
||||
@@ -124,14 +124,14 @@ func skip_suite(reason: String) -> void:
|
||||
|
||||
|
||||
## Mark the current test as skipped when the running Godot is older than
|
||||
## `min_version` (a "major.minor" string like "4.4"). Use for tests that
|
||||
## `min_version` (a "major.minor" string like "4.6"). Use for tests that
|
||||
## exercise an engine API or behavior that only exists on newer Godot.
|
||||
## Returns true when the test was skipped, so callers can `return` from
|
||||
## the test body.
|
||||
##
|
||||
## Example:
|
||||
## func test_uses_44_only_api() -> void:
|
||||
## if skip_on_godot_lt("4.4", "Engine.capture_script_backtraces is 4.4+"):
|
||||
## func test_uses_46_only_api() -> void:
|
||||
## if skip_on_godot_lt("4.6", "example API requires Godot 4.6+"):
|
||||
## return
|
||||
## ...
|
||||
func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
|
||||
|
||||
@@ -50,6 +50,8 @@ const DOMAINS := [
|
||||
{"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]},
|
||||
{"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]},
|
||||
{"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]},
|
||||
{"id": "tilemap", "label": "tilemap", "count": 1, "tools": ["tilemap_manage"]},
|
||||
{"id": "tileset", "label": "tileset", "count": 1, "tools": ["tileset_manage"]},
|
||||
{"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]},
|
||||
]
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ extends McpStructuredLogRing
|
||||
const MAX_LINES := 500
|
||||
|
||||
var _mutex := Mutex.new()
|
||||
var _error_appended_total := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -33,9 +34,10 @@ func _init() -> void:
|
||||
|
||||
|
||||
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
|
||||
var coerced_level := _coerce_level(level)
|
||||
var entry := {
|
||||
"source": "editor",
|
||||
"level": _coerce_level(level),
|
||||
"level": coerced_level,
|
||||
"text": text,
|
||||
"path": path,
|
||||
"line": line,
|
||||
@@ -45,6 +47,8 @@ func append(level: String, text: String, path: String = "", line: int = 0, funct
|
||||
entry["details"] = details.duplicate(true)
|
||||
_mutex.lock()
|
||||
_append_entry(entry)
|
||||
if coerced_level == "error":
|
||||
_error_appended_total += 1
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
@@ -96,9 +100,17 @@ func appended_total() -> int:
|
||||
return n
|
||||
|
||||
|
||||
func error_appended_total() -> int:
|
||||
_mutex.lock()
|
||||
var n := _error_appended_total
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
|
||||
func clear() -> int:
|
||||
_mutex.lock()
|
||||
var n := _total_count_unlocked()
|
||||
_clear_storage()
|
||||
_error_appended_total = 0
|
||||
_mutex.unlock()
|
||||
return n
|
||||
|
||||
@@ -6,9 +6,8 @@ extends McpStructuredLogRing
|
||||
## ferried back from the playing game over the EngineDebugger channel.
|
||||
##
|
||||
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
|
||||
## rotates each time clear_for_new_run() fires (called on the game's
|
||||
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
|
||||
## this play started".
|
||||
## rotates at play-start, giving agents a stable cursor for "lines from
|
||||
## this run" even when the game never reaches the mcp:hello boot beacon.
|
||||
##
|
||||
## Single-threaded — game_helper.gd drains its logger from `_process` and
|
||||
## calls `append` from the main thread, so this subclass can use the base
|
||||
@@ -17,6 +16,9 @@ extends McpStructuredLogRing
|
||||
const MAX_LINES := 2000
|
||||
|
||||
var _run_id := ""
|
||||
var _run_seq := 0
|
||||
var _error_warn_total := 0
|
||||
var _error_total := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -24,18 +26,30 @@ func _init() -> void:
|
||||
|
||||
|
||||
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
|
||||
var coerced_level := _coerce_level(level)
|
||||
var entry := {
|
||||
"source": "game",
|
||||
"level": coerced_level,
|
||||
"text": text,
|
||||
"run_id": _run_id,
|
||||
}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_append_entry(entry)
|
||||
if coerced_level in ["warn", "error"]:
|
||||
_error_warn_total += 1
|
||||
if coerced_level == "error":
|
||||
_error_total += 1
|
||||
|
||||
|
||||
## Rotate the run identifier and drop all buffered entries. Called when the
|
||||
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
|
||||
## Returns the new run_id.
|
||||
## Rotate the run identifier without dropping buffered entries. Called at
|
||||
## play-start so even no-hello parse failures get a fresh current-run identity.
|
||||
## Historical lines stay tagged with their original run_id and can still be
|
||||
## queried explicitly.
|
||||
func clear_for_new_run() -> String:
|
||||
_clear_storage()
|
||||
_run_id = _generate_run_id()
|
||||
_error_warn_total = 0
|
||||
_error_total = 0
|
||||
return _run_id
|
||||
|
||||
|
||||
@@ -43,8 +57,46 @@ func run_id() -> String:
|
||||
return _run_id
|
||||
|
||||
|
||||
static func _generate_run_id() -> String:
|
||||
func error_warn_total() -> int:
|
||||
return _error_warn_total
|
||||
|
||||
|
||||
func error_total() -> int:
|
||||
return _error_total
|
||||
|
||||
|
||||
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
|
||||
return get_run_page(run_id, offset, count).entries
|
||||
|
||||
|
||||
func run_total_count(run_id: String) -> int:
|
||||
return int(get_run_page(run_id, 0, 0).total_count)
|
||||
|
||||
|
||||
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
|
||||
var entries := _entries_for_run(run_id)
|
||||
var start := mini(maxi(0, offset), entries.size())
|
||||
var stop := mini(entries.size(), start + maxi(0, count))
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(start, stop):
|
||||
out.append(entries[i])
|
||||
return {
|
||||
"entries": out,
|
||||
"total_count": entries.size(),
|
||||
}
|
||||
|
||||
|
||||
func _entries_for_run(run_id: String) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for entry in get_range(0, total_count()):
|
||||
if str(entry.get("run_id", "")) == run_id:
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
func _generate_run_id() -> String:
|
||||
## Opaque to agents — they only check equality. Time-based is plenty
|
||||
## unique within a single editor session and avoids the RNG-seed
|
||||
## reproducibility footgun.
|
||||
return "r%d" % Time.get_ticks_msec()
|
||||
## unique within a single editor session; the local sequence protects
|
||||
## fast back-to-back test runs within the same millisecond.
|
||||
_run_seq += 1
|
||||
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
|
||||
|
||||
@@ -26,9 +26,13 @@ var _total_logged: int = 0
|
||||
var enabled := true
|
||||
|
||||
|
||||
func log(msg: String) -> void:
|
||||
## `echo=false` records the line into the ring (so the dock's log panel shows
|
||||
## it) without printing to the Godot console. Used for high-frequency
|
||||
## machine-driven lines like readiness flips, which spammed the console of
|
||||
## every install (#626) — filesystem scans toggle readiness on each import.
|
||||
func log(msg: String, echo: bool = true) -> void:
|
||||
var line := "MCP | %s" % msg
|
||||
if enabled and console_echo:
|
||||
if enabled and console_echo and echo:
|
||||
print(line)
|
||||
_lines.append(line)
|
||||
if _lines.size() > MAX_LINES:
|
||||
|
||||
@@ -57,6 +57,14 @@ static func has_worker_alive(state: int) -> bool:
|
||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||
|
||||
|
||||
## True while the status worker is still within its healthy budget. Once a
|
||||
## refresh has timed out, the dock keeps the warning badge but must let users
|
||||
## retry Configure / Configure all instead of stranding the controls behind an
|
||||
## orphaned, uninterruptible worker.
|
||||
static func should_disable_client_actions(state: int) -> bool:
|
||||
return state == RUNNING
|
||||
|
||||
|
||||
## True when the dock should reject new refresh spawns. Used by the
|
||||
## focus-in / manual button / cooldown-timer entrypoints.
|
||||
static func is_blocked_for_spawn(state: int) -> bool:
|
||||
|
||||
@@ -300,6 +300,15 @@ func _set_incompatible_server(live: Dictionary, expected_version: String, port:
|
||||
var proof_name := str(proof.get("proof", ""))
|
||||
_can_recover_incompatible = not proof_name.is_empty()
|
||||
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
|
||||
if not _can_recover_incompatible:
|
||||
## Non-recoverable: a foreign / unprovable occupant holds the port and
|
||||
## we have no ownership proof, so we must NOT kill it — surface a
|
||||
## concrete free port the user can switch to instead (the same hint
|
||||
## the dock crash body renders). Logging it to the editor output also
|
||||
## lets `ci-stale-server-smoke --mode foreign` assert this upstream
|
||||
## classification from CI. Reservation-aware on Windows.
|
||||
var suggested := ClientConfigurator.suggest_free_port(port + 1)
|
||||
print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, suggested])
|
||||
_host._refresh_dock_client_statuses()
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ const SETTING_HTTP_PORT := "godot_ai/http_port"
|
||||
## Comma-separated list of tool domains excluded from the server at spawn time.
|
||||
const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains"
|
||||
const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled"
|
||||
## Whether MCP log lines echo to the Godot console (dock "Log" toggle).
|
||||
## The dock's ring-buffer log panel keeps recording regardless.
|
||||
const SETTING_MCP_LOGGING := "godot_ai/mcp_logging"
|
||||
|
||||
|
||||
## Returns true if the string value is truthy
|
||||
@@ -37,3 +40,21 @@ static func telemetry_enabled() -> bool:
|
||||
if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED):
|
||||
return bool(es.get_setting(SETTING_TELEMETRY_ENABLED))
|
||||
return true
|
||||
|
||||
|
||||
## Returns whether MCP log lines should echo to the Godot console. Read at
|
||||
## plugin startup (to apply the persisted choice to the log buffer and
|
||||
## dispatcher) and by the dock's LogViewer toggle for its initial state.
|
||||
## Defaults to true when the user has never touched the toggle.
|
||||
static func mcp_logging_enabled() -> bool:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null and es.has_setting(SETTING_MCP_LOGGING):
|
||||
return bool(es.get_setting(SETTING_MCP_LOGGING))
|
||||
return true
|
||||
|
||||
|
||||
## Persist the dock "Log" toggle so the choice survives editor restarts (#626).
|
||||
static func set_mcp_logging_enabled(enabled: bool) -> void:
|
||||
var es := EditorInterface.get_editor_settings()
|
||||
if es != null:
|
||||
es.set_setting(SETTING_MCP_LOGGING, enabled)
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
@tool
|
||||
class_name McpSurfacedErrorTracker
|
||||
extends RefCounted
|
||||
|
||||
## Central source for "errors the agent should know exist".
|
||||
##
|
||||
## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
|
||||
## game subprocess can land solely in the Debugger Errors tab, so this tracker
|
||||
## promotes visible Debugger-tab rows into a monotonic sequence before the
|
||||
## dispatcher stamps a watermark on each response envelope.
|
||||
|
||||
const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
|
||||
const MAX_PROMOTED_DEBUGGER_KEYS := 5000
|
||||
const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
|
||||
const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
|
||||
## #641: delays for the self-scheduled forced scans armed on run stop (and on
|
||||
## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
|
||||
## the remote debugger delivers right around the event, and a late one past
|
||||
## Godot's per-frame Errors-tab insertion throttle for error floods.
|
||||
const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
|
||||
|
||||
var _editor_log_buffer
|
||||
var _game_log_buffer
|
||||
var _debugger_errors_root: Node
|
||||
var _debugger_search_root_cache: Node
|
||||
var _promoted_debugger_keys: Dictionary = {}
|
||||
var _promoted_debugger_key_order: Array[String] = []
|
||||
var _promoted_debugger_entries: Array[Dictionary] = []
|
||||
var _debugger_promoted_total := 0
|
||||
var _run_seq := 0
|
||||
var _oldest_retained_debugger_sequence := 1
|
||||
var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
||||
var _debugger_scan_active := false
|
||||
var _debugger_scan_until_msec := 0
|
||||
var _deferred_scans_scheduled_total := 0
|
||||
|
||||
|
||||
func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
_game_log_buffer = game_log_buffer
|
||||
_debugger_errors_root = debugger_errors_root
|
||||
|
||||
|
||||
func note_game_run_started(sticky_scan: bool = true) -> void:
|
||||
_run_seq += 1
|
||||
_debugger_scan_active = sticky_scan
|
||||
_debugger_scan_until_msec = 0
|
||||
if not sticky_scan:
|
||||
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
refresh_debugger_errors(true)
|
||||
|
||||
|
||||
func note_game_run_stopped() -> void:
|
||||
_debugger_scan_active = false
|
||||
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
schedule_deferred_scans()
|
||||
|
||||
|
||||
## #641: promotion into the watermark used to depend on a tool call arriving
|
||||
## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
|
||||
## of stop). Boot parse errors that landed in the Errors tab with no tool call
|
||||
## in that window were never promoted, so the agent never got the
|
||||
## new_errors_since_last_call hint. These editor-side timers force a scan
|
||||
## regardless of tool-call cadence; the next stamped response then carries the
|
||||
## already-promoted count even after the gate closes. Scans are content-keyed
|
||||
## and idempotent, so a timer firing after an unrelated new run is harmless.
|
||||
func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
|
||||
var tree := Engine.get_main_loop() as SceneTree
|
||||
if tree == null:
|
||||
return
|
||||
for delay in delays:
|
||||
var timer := tree.create_timer(maxf(0.05, float(delay)))
|
||||
timer.timeout.connect(_on_deferred_scan_timeout)
|
||||
_deferred_scans_scheduled_total += 1
|
||||
|
||||
|
||||
func deferred_scans_scheduled_total() -> int:
|
||||
return _deferred_scans_scheduled_total
|
||||
|
||||
|
||||
func _on_deferred_scan_timeout() -> void:
|
||||
refresh_debugger_errors(true)
|
||||
|
||||
|
||||
func refresh_debugger_errors(force: bool = true) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if not force and not _should_scan_debugger_for_cached_watermark(now):
|
||||
return
|
||||
_last_debugger_refresh_msec = now
|
||||
var current_by_key: Dictionary = {}
|
||||
for entry in _raw_debugger_error_entries():
|
||||
if str(entry.get("level", "")) != "error":
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry})
|
||||
info["count"] = int(info.get("count", 0)) + 1
|
||||
current_by_key[key] = info
|
||||
for key in _promoted_debugger_keys.keys():
|
||||
if not current_by_key.has(key):
|
||||
_promoted_debugger_keys[key] = 0
|
||||
for key in current_by_key.keys():
|
||||
var info: Dictionary = current_by_key[key]
|
||||
var current := int(info.get("count", 0))
|
||||
var stored := int(_promoted_debugger_keys.get(key, 0))
|
||||
if current < stored:
|
||||
_promoted_debugger_keys[key] = current
|
||||
continue
|
||||
if current == stored:
|
||||
continue
|
||||
if not _promoted_debugger_keys.has(key):
|
||||
_promoted_debugger_key_order.append(key)
|
||||
_promoted_debugger_keys[key] = current
|
||||
_debugger_promoted_total += current - stored
|
||||
var source_entry: Dictionary = info.get("entry", {})
|
||||
var promoted := source_entry.duplicate(true)
|
||||
promoted["_debugger_key"] = key
|
||||
promoted["_debugger_occurrences"] = current
|
||||
promoted["_debugger_sequence"] = _debugger_promoted_total
|
||||
_remove_promoted_debugger_entry(key)
|
||||
_promoted_debugger_entries.append(promoted)
|
||||
_trim_promoted_debugger_entries()
|
||||
_trim_promoted_debugger_key_counts()
|
||||
|
||||
|
||||
## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
|
||||
## boot-time parse error that parked the game in a remote-debugger break before
|
||||
## any surface got a record. The entry joins the same promoted sequence as
|
||||
## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
|
||||
## fallback, and the response watermark all see it with no extra plumbing.
|
||||
## Re-recording the same key later (the same script still broken on the next
|
||||
## run) re-promotes it with a fresh sequence, mirroring how re-appearing
|
||||
## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
|
||||
## break ends since the row never exists in the live tab.
|
||||
func record_synthetic_error(entry: Dictionary) -> void:
|
||||
var key := _log_entry_key(entry)
|
||||
var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
|
||||
if not _promoted_debugger_keys.has(key):
|
||||
_promoted_debugger_key_order.append(key)
|
||||
_promoted_debugger_keys[key] = occurrences
|
||||
_debugger_promoted_total += 1
|
||||
var promoted := entry.duplicate(true)
|
||||
promoted["_debugger_key"] = key
|
||||
promoted["_debugger_occurrences"] = occurrences
|
||||
promoted["_debugger_sequence"] = _debugger_promoted_total
|
||||
promoted["_debugger_synthetic"] = true
|
||||
_remove_promoted_debugger_entry(key)
|
||||
_promoted_debugger_entries.append(promoted)
|
||||
_trim_promoted_debugger_entries()
|
||||
_trim_promoted_debugger_key_counts()
|
||||
|
||||
|
||||
func watermark(force_debugger_scan: bool = false) -> Dictionary:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
return {
|
||||
"run_seq": _run_seq,
|
||||
"editor_ring": _error_appended_total(),
|
||||
"debugger_promoted": _debugger_promoted_total,
|
||||
"game_error_warn": _game_error_total(),
|
||||
}
|
||||
|
||||
|
||||
static func stamp_watermark(response: Dictionary, tracker) -> void:
|
||||
if tracker == null:
|
||||
return
|
||||
if not tracker.has_method("watermark"):
|
||||
return
|
||||
response["error_watermark"] = tracker.watermark()
|
||||
|
||||
|
||||
func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
return _debugger_promoted_total
|
||||
|
||||
|
||||
func collect_editor_log_entries() -> Array[Dictionary]:
|
||||
refresh_debugger_errors(true)
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
if _editor_log_buffer != null:
|
||||
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
entries.append(entry)
|
||||
for entry in read_debugger_error_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
## #645: synthesized break records have no live Errors-tab row to scrape —
|
||||
## merge them from the promoted list so logs_read(source="editor") shows
|
||||
## the record that run/game responses point at.
|
||||
for entry in _promoted_debugger_entries:
|
||||
if not bool(entry.get("_debugger_synthetic", false)):
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(_strip_promotion_bookkeeping(entry))
|
||||
return entries
|
||||
|
||||
|
||||
static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
|
||||
var clean := entry.duplicate(true)
|
||||
for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
|
||||
clean.erase(key)
|
||||
return clean
|
||||
|
||||
|
||||
func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
|
||||
refresh_debugger_errors(force_debugger_scan)
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
var truncated := false
|
||||
if _editor_log_buffer != null:
|
||||
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
|
||||
truncated = bool(captured.get("truncated", false))
|
||||
for entry in captured.get("entries", []):
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
entries.append(entry)
|
||||
if debugger_cursor < _oldest_retained_debugger_sequence - 1:
|
||||
truncated = true
|
||||
for entry in _promoted_debugger_entries:
|
||||
if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
|
||||
continue
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return {
|
||||
"entries": entries,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
|
||||
func retained_recent_editor_entries() -> Array[Dictionary]:
|
||||
## There is no shared timestamp across the editor logger ring and Godot's
|
||||
## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
|
||||
## buffered editor entries first, then debugger-only rows that were not in
|
||||
## the ring, so stale Debugger rows cannot outrank newer ring entries.
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
if _editor_log_buffer != null:
|
||||
entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
|
||||
entries.reverse()
|
||||
for entry in entries:
|
||||
seen_keys[_log_entry_key(entry)] = true
|
||||
for entry in collect_editor_log_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func read_debugger_error_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
var seen_keys: Dictionary = {}
|
||||
for entry in _raw_debugger_error_entries():
|
||||
var key := _log_entry_key(entry)
|
||||
if seen_keys.has(key):
|
||||
continue
|
||||
seen_keys[key] = true
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
func locate_debugger_error_trees() -> Array[Tree]:
|
||||
var trees: Array[Tree] = []
|
||||
var root: Node = _debugger_errors_root
|
||||
## #641: a deferred-scan timer can outlive an injected root (tests,
|
||||
## teardown). A freed root must not fall through to the live editor UI —
|
||||
## that would promote unrelated real errors into a tracker scoped to the
|
||||
## dead root — so treat it as "nothing to scan".
|
||||
if root != null and not is_instance_valid(root):
|
||||
return trees
|
||||
if root == null:
|
||||
root = _debugger_search_root()
|
||||
if root == null:
|
||||
return trees
|
||||
_collect_debugger_error_trees(root, trees)
|
||||
return trees
|
||||
|
||||
|
||||
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):
|
||||
## Synthetic roots in tests do not have Godot's Clear button.
|
||||
tree.clear()
|
||||
return cleared
|
||||
|
||||
|
||||
func _debugger_search_root() -> Node:
|
||||
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 _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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
## Shared one-line rendering of a compact editor-error entry for messages and
|
||||
## hints ("text (path:line)"). Single home so the debugger plugin, project
|
||||
## handler, and editor handler can't drift apart.
|
||||
static func format_editor_error_summary(entry: Dictionary) -> String:
|
||||
var text := str(entry.get("text", "editor error"))
|
||||
var path := str(entry.get("path", ""))
|
||||
var line := int(entry.get("line", 0))
|
||||
if not path.is_empty() and line > 0:
|
||||
return "%s (%s:%d)" % [text, path, line]
|
||||
if not path.is_empty():
|
||||
return "%s (%s)" % [text, path]
|
||||
return text
|
||||
|
||||
|
||||
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)),
|
||||
]
|
||||
|
||||
|
||||
func _error_appended_total() -> int:
|
||||
if _editor_log_buffer == null:
|
||||
return 0
|
||||
if _editor_log_buffer.has_method("error_appended_total"):
|
||||
return int(_editor_log_buffer.call("error_appended_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _game_error_total() -> int:
|
||||
if _game_log_buffer == null:
|
||||
return 0
|
||||
if _game_log_buffer.has_method("error_total"):
|
||||
return int(_game_log_buffer.call("error_total"))
|
||||
return 0
|
||||
|
||||
|
||||
func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
|
||||
if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
|
||||
return false
|
||||
return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
|
||||
|
||||
|
||||
func _trim_promoted_debugger_entries() -> void:
|
||||
while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
|
||||
_promoted_debugger_entries.pop_front()
|
||||
if _promoted_debugger_entries.is_empty():
|
||||
_oldest_retained_debugger_sequence = _debugger_promoted_total + 1
|
||||
else:
|
||||
_oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
|
||||
|
||||
|
||||
func _trim_promoted_debugger_key_counts() -> void:
|
||||
while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
|
||||
var key := _promoted_debugger_key_order.pop_front()
|
||||
_promoted_debugger_keys.erase(key)
|
||||
|
||||
|
||||
func _remove_promoted_debugger_entry(key: String) -> void:
|
||||
for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
|
||||
if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
|
||||
_promoted_debugger_entries.remove_at(i)
|
||||
return
|
||||
|
||||
|
||||
func _raw_debugger_error_entries() -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
for tree in locate_debugger_error_trees():
|
||||
entries.append_array(entries_from_debugger_error_tree(tree))
|
||||
return entries
|
||||
@@ -0,0 +1 @@
|
||||
uid://o0ulahkt83re
|
||||
@@ -124,13 +124,9 @@ func clear_pending_download() -> void:
|
||||
_latest_checksum_url = ""
|
||||
|
||||
|
||||
## True when the running Godot can self-update in place. Godot < 4.4 takes
|
||||
## the `_install_zip_inline` extract-then-restart path, and that engine's
|
||||
## stricter `GDScript::reload()` (`!p_keep_state && has_instances` ->
|
||||
## `ERR_ALREADY_IN_USE`) turns the extract-over-live-scripts into a reload
|
||||
## error flood plus a SIGSEGV in `EditorDockManager::remove_dock` /
|
||||
## `SceneTree::finalize` on the restart/quit (#475). So on < 4.4 we don't
|
||||
## run the in-editor pipeline at all — the user updates manually.
|
||||
## True when the running Godot is within the supported self-update floor.
|
||||
## Godot < 4.5 must not be offered a one-click update to a release whose
|
||||
## always-loaded scripts depend on 4.5 APIs/classes.
|
||||
## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified.
|
||||
func _can_self_update() -> bool:
|
||||
var v := Engine.get_version_info()
|
||||
@@ -138,49 +134,36 @@ func _can_self_update() -> bool:
|
||||
|
||||
|
||||
## Pure version predicate, split out so it's testable without faking the
|
||||
## running engine. In-editor self-update needs Godot >= 4.4.
|
||||
## running engine. In-editor self-update needs Godot >= 4.5.
|
||||
static func _version_can_self_update(major: int, minor: int) -> bool:
|
||||
return major > 4 or (major == 4 and minor >= 4)
|
||||
return major > 4 or (major == 4 and minor >= 5)
|
||||
|
||||
|
||||
## Banner guidance for the gated (< 4.4) path. Shown up-front at check time
|
||||
## (with the available version) and again on click, so the user understands
|
||||
## the manual-update flow before they press anything. Single source of truth
|
||||
## so check-time and click-time text never drift.
|
||||
## Banner guidance for engines below the support floor. Shown up-front at
|
||||
## check time so those users do not install an incompatible latest release.
|
||||
static func _manual_update_label(version: String) -> String:
|
||||
var prefix := "Update available"
|
||||
var release_noun := "release"
|
||||
var suffix := ""
|
||||
if not version.is_empty():
|
||||
prefix = "Update v%s available" % version
|
||||
release_noun = "version"
|
||||
suffix = " (latest: v%s)" % version
|
||||
return (
|
||||
prefix
|
||||
+ " — in-editor update needs Godot 4.4+. Open the download page, then "
|
||||
+ "replace addons/godot_ai/ manually and relaunch."
|
||||
"This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix]
|
||||
+ "Upgrade to Godot 4.5+ to keep receiving updates."
|
||||
)
|
||||
|
||||
|
||||
## Driven by the dock's Update button. On Godot < 4.4 (see `_can_self_update`)
|
||||
## the in-editor install is disabled — we open the release page for a manual
|
||||
## download instead, never entering the extract pipeline that crashes those
|
||||
## engines. With no resolved download URL — either the check never completed,
|
||||
## or the release didn't ship a matching asset — also falls back to opening
|
||||
## the release page. Otherwise kicks off the download → extract → reload
|
||||
## pipeline.
|
||||
## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update)
|
||||
## the in-editor install is disabled so users cannot install an incompatible
|
||||
## latest release. With no resolved download URL, falls back to opening the
|
||||
## release page. Otherwise kicks off the download -> extract -> reload pipeline.
|
||||
func start_install() -> void:
|
||||
if not _can_self_update():
|
||||
## Only claim success + lock the button if the browser actually opened.
|
||||
## On failure (no handler, headless) keep the button enabled so the
|
||||
## user can retry. Either way, leave the version-bearing guidance label
|
||||
## from check time in place — don't re-emit label_text.
|
||||
if OS.shell_open(RELEASES_PAGE) == OK:
|
||||
install_state_changed.emit({
|
||||
"button_text": "Opened download page",
|
||||
"button_disabled": true,
|
||||
})
|
||||
else:
|
||||
install_state_changed.emit({
|
||||
"button_text": "Couldn't open browser — retry",
|
||||
"button_disabled": false,
|
||||
})
|
||||
install_state_changed.emit({
|
||||
"button_text": "Upgrade Godot",
|
||||
"button_disabled": true,
|
||||
"label_text": _manual_update_label(""),
|
||||
"banner_visible": true,
|
||||
})
|
||||
return
|
||||
|
||||
if _latest_download_url.is_empty():
|
||||
@@ -232,7 +215,6 @@ func start_install() -> void:
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
## Consulted by the dock's spawn paths (focus-in refresh, manual button,
|
||||
## deferred initial refresh) — true while plugin scripts are being
|
||||
## overwritten. A worker mid-`GDScriptFunction::call` into a half-
|
||||
@@ -314,6 +296,8 @@ static func _is_trusted_download_url(url: String) -> bool:
|
||||
const SCHEME := "https://"
|
||||
if not url.begins_with(SCHEME):
|
||||
return false
|
||||
if url.find("\\") >= 0:
|
||||
return false
|
||||
var rest := url.substr(SCHEME.length())
|
||||
var authority := rest
|
||||
var slash := rest.find("/")
|
||||
@@ -353,17 +337,17 @@ func _on_update_check_completed(
|
||||
var parsed := parse_releases_response(result, response_code, body)
|
||||
if not bool(parsed.get("has_update", false)):
|
||||
return
|
||||
if not _can_self_update():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Upgrade Godot",
|
||||
"button_disabled": true,
|
||||
"label_text": _manual_update_label(String(parsed.get("version", ""))),
|
||||
"banner_visible": true,
|
||||
})
|
||||
return
|
||||
_latest_download_url = String(parsed.get("download_url", ""))
|
||||
_latest_checksum_url = String(parsed.get("checksum_url", ""))
|
||||
update_check_completed.emit(parsed)
|
||||
## On engines that can't self-update (Godot < 4.4, #475), surface the
|
||||
## full manual-update guidance AND relabel the button up-front — before
|
||||
## any click — so the user knows what the button does and why.
|
||||
if not _can_self_update():
|
||||
install_state_changed.emit({
|
||||
"button_text": "Open download page",
|
||||
"label_text": _manual_update_label(String(parsed.get("version", ""))),
|
||||
})
|
||||
|
||||
|
||||
func _on_download_completed(
|
||||
@@ -457,7 +441,7 @@ func _on_checksum_completed(
|
||||
|
||||
print("MCP | self-update checksum verified (sha256 %s)" % actual)
|
||||
install_state_changed.emit({"button_text": "Installing..."})
|
||||
_install_zip()
|
||||
_install_zip.call_deferred()
|
||||
|
||||
|
||||
## Surface an integrity-check failure and drop the staged zip so the bad
|
||||
@@ -483,8 +467,9 @@ static func _parse_sha256_digest(text: String) -> String:
|
||||
if trimmed.is_empty():
|
||||
return ""
|
||||
## First whitespace-delimited token; `sha256sum` separates digest and
|
||||
## filename with two spaces, so allow_empty=false collapses the run.
|
||||
var tokens := trimmed.split(" ", false)
|
||||
## filename with two spaces, but some tools use tabs.
|
||||
var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ")
|
||||
var tokens := normalized.split(" ", false)
|
||||
if tokens.is_empty():
|
||||
return ""
|
||||
var digest := String(tokens[0]).strip_edges().to_lower()
|
||||
@@ -512,167 +497,34 @@ func _install_zip() -> void:
|
||||
return
|
||||
|
||||
## Drain in-flight workers + block new ones BEFORE any disk write.
|
||||
## Without this, focus-in landing in the extract→reload window spawns
|
||||
## Without this, focus-in landing in the extract -> reload window spawns
|
||||
## a worker that walks into a partially-overwritten script and
|
||||
## SIGABRTs in `GDScriptFunction::call`.
|
||||
_install_in_flight = true
|
||||
_drain_dock_workers()
|
||||
|
||||
var version := Engine.get_version_info()
|
||||
var has_runner: bool = (
|
||||
_plugin != null
|
||||
and _plugin.has_method("install_downloaded_update")
|
||||
)
|
||||
## Same major-aware predicate as the _can_self_update() gate, so a future
|
||||
## Godot 5.x (minor 0) takes the runner path the gate promised — not the
|
||||
## pre-4.4 inline extract. A bare `minor >= 4` here would route 5.0 to the
|
||||
## crash-prone inline path even though the gate let it in.
|
||||
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))) and has_runner:
|
||||
if has_runner:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
## Runner takes over: plugin tears down, runner extracts + scans +
|
||||
## re-enables. `install_downloaded_update` calls
|
||||
## `prepare_for_update_reload()` internally (kills the server,
|
||||
## resets the spawn guard) — see plugin.gd::install_downloaded_update.
|
||||
## resets the spawn guard) - see plugin.gd::install_downloaded_update.
|
||||
_plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock)
|
||||
return
|
||||
|
||||
_install_zip_inline(version)
|
||||
|
||||
|
||||
func _install_zip_inline(version: Dictionary) -> void:
|
||||
## Pre-4.4 fallback. EditorInterface.set_plugin_enabled off/on is
|
||||
## re-entry-unsafe on older Godot; we extract in-process and ask the
|
||||
## user to restart.
|
||||
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
|
||||
var install_base := ProjectSettings.globalize_path("res://")
|
||||
|
||||
var reader := ZIPReader.new()
|
||||
if reader.open(zip_path) != OK:
|
||||
_install_in_flight = false
|
||||
install_state_changed.emit({
|
||||
"button_text": "Extract failed",
|
||||
"button_disabled": false,
|
||||
})
|
||||
return
|
||||
|
||||
var files := reader.get_files()
|
||||
for file_path in files:
|
||||
if not file_path.begins_with("addons/godot_ai/"):
|
||||
continue
|
||||
## Skip zip dir entries; parent dirs are created from each validated
|
||||
## file's base dir below — the same shape the runner uses. Creating a
|
||||
## dir from an unvalidated entry would itself be a traversal hole.
|
||||
if file_path.ends_with("/"):
|
||||
continue
|
||||
## Reject path-traversal / absolute / backslash entries BEFORE any
|
||||
## path_join + write. The modern runner enforces this via
|
||||
## `update_reload_runner.gd::_is_safe_zip_addon_file`; the pre-4.4
|
||||
## inline path used to gate only on the `addons/godot_ai/` prefix, so
|
||||
## `addons/godot_ai/../../evil.gd` escaped the addon dir. This guard
|
||||
## closes that gap so the weaker path runs the same checks. See #522.
|
||||
if not _is_safe_zip_addon_file(file_path):
|
||||
_abort_inline_install(reader, "unsafe zip path: %s" % file_path)
|
||||
return
|
||||
var dir := file_path.get_base_dir()
|
||||
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
|
||||
var content := reader.read_file(file_path)
|
||||
var target := install_base.path_join(file_path)
|
||||
var f := FileAccess.open(target, FileAccess.WRITE)
|
||||
## Unlike the runner (tmp+rename+per-file backup+rollback), this pre-4.4
|
||||
## path writes directly over live files and can't roll back. It used to
|
||||
## skip a null open and ignore store_buffer errors silently, leaving a
|
||||
## partially-overwritten addons tree while still telling the user to
|
||||
## restart onto it. Check both error surfaces and abort loudly instead.
|
||||
## See #524.
|
||||
if f == null:
|
||||
_abort_inline_install(
|
||||
reader,
|
||||
"could not open %s for write (error %d)" % [target, FileAccess.get_open_error()],
|
||||
)
|
||||
return
|
||||
f.store_buffer(content)
|
||||
var write_error := f.get_error()
|
||||
f.close()
|
||||
if write_error != OK:
|
||||
_abort_inline_install(reader, "write error %d for %s" % [write_error, target])
|
||||
return
|
||||
|
||||
reader.close()
|
||||
|
||||
DirAccess.remove_absolute(zip_path)
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR))
|
||||
|
||||
## Kill the old server before the reload so the re-enabled plugin spawns
|
||||
## a fresh one against the new plugin version (#132).
|
||||
if _plugin != null and _plugin.has_method("prepare_for_update_reload"):
|
||||
_plugin.prepare_for_update_reload()
|
||||
|
||||
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))):
|
||||
install_state_changed.emit({"button_text": "Scanning..."})
|
||||
## Filesystem scan must complete before plugin reload — otherwise
|
||||
## plugin.gd re-parses against a ClassDB that hasn't seen the new
|
||||
## files yet, parse errors, dock tears down silently. See #127.
|
||||
var fs := EditorInterface.get_resource_filesystem()
|
||||
if fs != null:
|
||||
fs.filesystem_changed.connect(
|
||||
_on_filesystem_scanned_for_update, CONNECT_ONE_SHOT
|
||||
)
|
||||
fs.scan()
|
||||
else:
|
||||
_reload_after_update.call_deferred()
|
||||
else:
|
||||
## Pre-4.4: no plugin reload; refreshes resume on the old dock
|
||||
## instance until the user restarts.
|
||||
_install_in_flight = false
|
||||
install_state_changed.emit({
|
||||
"button_text": "Restart editor to apply",
|
||||
"button_disabled": true,
|
||||
"label_text": "Updated! Restart the editor.",
|
||||
"outcome": "success",
|
||||
})
|
||||
|
||||
|
||||
## Abort the inline (pre-4.4) extract on a path-safety or write failure.
|
||||
## Closes the ZIP reader, drops the in-flight gate so dock spawn paths
|
||||
## un-block, and surfaces the failure loudly: this path has no rollback, so
|
||||
## the addons tree may be partially overwritten and the user must reinstall
|
||||
## from the download page rather than relaunch onto a half-written plugin.
|
||||
## See #522 / #524.
|
||||
func _abort_inline_install(reader: ZIPReader, reason: String) -> void:
|
||||
reader.close()
|
||||
_install_in_flight = false
|
||||
push_error(
|
||||
"MCP | self-update extract failed: %s. addons/godot_ai/ may be"
|
||||
% reason
|
||||
+ " partially updated — reinstall the plugin from the download page"
|
||||
+ " before relaunching."
|
||||
)
|
||||
print("MCP | self-update extract aborted: %s" % reason)
|
||||
install_state_changed.emit({
|
||||
"button_text": "Extract failed — reinstall",
|
||||
"button_text": "Reload runner missing",
|
||||
"button_disabled": false,
|
||||
})
|
||||
|
||||
|
||||
## Mirror of `update_reload_runner.gd::_is_safe_zip_addon_file`. Rejects any
|
||||
## entry that could escape `addons/godot_ai/` — absolute paths, backslashes,
|
||||
## and `.`/`..`/empty path segments — before it reaches a `path_join` + write
|
||||
## on the inline (pre-4.4) extract path, which has no rollback. Static so the
|
||||
## guard is unit-testable without instancing the manager. See #522.
|
||||
static func _is_safe_zip_addon_file(file_path: String) -> bool:
|
||||
if file_path.is_absolute_path() or file_path.contains("\\"):
|
||||
return false
|
||||
if not file_path.begins_with("addons/godot_ai/"):
|
||||
return false
|
||||
var rel_path := file_path.trim_prefix("addons/godot_ai/")
|
||||
if rel_path.is_empty() or rel_path.ends_with("/"):
|
||||
return false
|
||||
for segment in rel_path.split("/", true):
|
||||
if segment.is_empty() or segment == "." or segment == "..":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _on_filesystem_scanned_for_update() -> void:
|
||||
install_state_changed.emit({"button_text": "Reloading..."})
|
||||
_reload_after_update.call_deferred()
|
||||
|
||||
Reference in New Issue
Block a user