feat: bullrush branch - mekton bulls arena, HUD, NPC managers, godot_ai updates
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.3+ (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,21 @@ static func run(
|
||||
) -> Dictionary:
|
||||
if exe.is_empty():
|
||||
return _spawn_failed_result()
|
||||
if _uses_blocking_legacy_path():
|
||||
## Godot 4.3 keeps the old blocking path because execute_with_pipe
|
||||
## capture/exit semantics differ there. The bounded timeout/kill
|
||||
## behavior is available on Godot 4.4+ only.
|
||||
return _run_blocking_legacy(exe, args)
|
||||
|
||||
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 +92,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,
|
||||
@@ -107,6 +129,27 @@ static func run(
|
||||
}
|
||||
|
||||
|
||||
static func _run_blocking_legacy(exe: String, args: Array) -> Dictionary:
|
||||
## Godot 4.3's OS.execute_with_pipe has capture/exit-code differences
|
||||
## locked by the 4.3 canary skips in test_cli_exec.gd. Preserve the old
|
||||
## blocking discovery behavior there so startup-critical probes keep the
|
||||
## same semantics that worked before the bounded-pipe path landed.
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(exe, args, output, true)
|
||||
var lines := PackedStringArray()
|
||||
for line in output:
|
||||
lines.append(str(line))
|
||||
var stdout := "\n".join(lines)
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": "",
|
||||
"output": stdout,
|
||||
"timed_out": false,
|
||||
"spawn_failed": exit_code == -1,
|
||||
}
|
||||
|
||||
|
||||
static func _spawn_failed_result() -> Dictionary:
|
||||
return {
|
||||
"exit_code": -1,
|
||||
@@ -119,9 +162,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:
|
||||
@@ -141,3 +194,10 @@ static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
|
||||
(stdio as FileAccess).close()
|
||||
if stderr_pipe is FileAccess:
|
||||
(stderr_pipe as FileAccess).close()
|
||||
|
||||
|
||||
static func _uses_blocking_legacy_path() -> bool:
|
||||
var version := Engine.get_version_info()
|
||||
var major := int(version.get("major", 4))
|
||||
var minor := int(version.get("minor", 0))
|
||||
return major < 4 or (major == 4 and minor < 4)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,7 @@ const EVAL_PROBE_INTERVAL_SEC := 0.35
|
||||
|
||||
var _log_buffer: McpLogBuffer
|
||||
var _game_log_buffer: McpGameLogBuffer
|
||||
var _editor_log_buffer: McpEditorLogBuffer
|
||||
|
||||
## Pending request_id -> {connection, timer, timeout_callable}.
|
||||
## We retain the bound timeout lambda so `_clear_pending` can disconnect
|
||||
@@ -80,12 +81,16 @@ var _game_run_token := 0
|
||||
var _ready_run_token := -1
|
||||
var _game_session_id := -1
|
||||
var _game_run_active := false
|
||||
var _game_run_started_msec := 0
|
||||
var _game_run_started_editor_cursor := 0
|
||||
var _game_helper_expected := true
|
||||
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) -> void:
|
||||
_log_buffer = log_buffer
|
||||
_game_log_buffer = game_log_buffer
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
|
||||
|
||||
func _has_capture(prefix: String) -> bool:
|
||||
@@ -107,14 +112,23 @@ func _setup_session(session_id: int) -> void:
|
||||
_game_session_id = session_id
|
||||
|
||||
|
||||
func begin_game_run() -> void:
|
||||
func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void:
|
||||
_game_run_token += 1
|
||||
_game_run_active = true
|
||||
_game_ready = false
|
||||
_ready_run_token = -1
|
||||
_game_session_id = -1
|
||||
_game_run_started_msec = Time.get_ticks_msec()
|
||||
_game_run_started_editor_cursor = maxi(0, editor_log_cursor)
|
||||
_game_helper_expected = helper_expected
|
||||
var run_id := ""
|
||||
if _game_log_buffer:
|
||||
run_id = _game_log_buffer.clear_for_new_run()
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] game capture pending run token %d" % _game_run_token)
|
||||
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 end_game_run() -> void:
|
||||
@@ -128,6 +142,158 @@ 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:
|
||||
if 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"
|
||||
return with_liveness_flags({
|
||||
"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,
|
||||
})
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
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."
|
||||
"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
|
||||
|
||||
|
||||
func recent_editor_errors_since(cursor: int) -> Dictionary:
|
||||
return _recent_editor_errors_since(cursor)
|
||||
|
||||
|
||||
func _recent_editor_errors_since(cursor: int) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var truncated := false
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
func _capture(message: String, data: Array, session_id: int) -> bool:
|
||||
## Godot passes the full "prefix:tail" string as `message`.
|
||||
match message:
|
||||
@@ -152,18 +318,15 @@ 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")
|
||||
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)
|
||||
@@ -262,8 +425,8 @@ func _wait_then_send(
|
||||
while not is_game_capture_ready() 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 +520,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)
|
||||
|
||||
|
||||
@@ -447,8 +617,8 @@ func _wait_then_eval(
|
||||
## 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)
|
||||
|
||||
@@ -704,8 +874,8 @@ func _wait_then_game_command(
|
||||
while not is_game_capture_ready() 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)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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,
|
||||
|
||||
@@ -28,6 +28,7 @@ func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_
|
||||
|
||||
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 +39,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 +76,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 +87,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 +95,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 +123,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,21 +136,35 @@ 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)
|
||||
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)
|
||||
return {
|
||||
"data": {
|
||||
"source": "game",
|
||||
"lines": page,
|
||||
"total_count": _game_log_buffer.total_count(),
|
||||
"total_count": int(run_page.get("total_count", 0)),
|
||||
"returned_count": page.size(),
|
||||
"offset": offset,
|
||||
"run_id": _game_log_buffer.run_id(),
|
||||
"is_running": EditorInterface.is_playing_scene(),
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,21 +244,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 +270,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -717,6 +757,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 +808,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,173 @@ 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)
|
||||
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||
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
|
||||
_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)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
func _run_project_already_running_message(decision: Dictionary) -> String:
|
||||
var state := str(decision.get("liveness_status", "unknown"))
|
||||
match state:
|
||||
"live":
|
||||
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."
|
||||
"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
|
||||
decision["message"] = "Game launched and the Godot AI game helper is live."
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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,56 @@ static func _validate_resource_class(type_str: String) -> Variant:
|
||||
return null
|
||||
|
||||
|
||||
## 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 ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % 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 +281,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 +319,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,6 +409,12 @@ 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):
|
||||
# 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 ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
|
||||
if ClassDB.is_parent_class(type_str, "Node"):
|
||||
return ErrorCodes.make(
|
||||
@@ -396,3 +450,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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+281
-46
@@ -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,12 +69,13 @@ 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -1480,6 +1632,30 @@ func _update_dev_section_buttons() -> void:
|
||||
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
|
||||
|
||||
|
||||
func _configured_client_count() -> int:
|
||||
var configured := 0
|
||||
for client_id in _client_rows:
|
||||
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
||||
if status == Client.Status.CONFIGURED:
|
||||
configured += 1
|
||||
return configured
|
||||
|
||||
|
||||
func _client_status_refresh_has_completed() -> bool:
|
||||
return _last_client_status_refresh_completed_msec > 0
|
||||
|
||||
|
||||
func _connected_status_text() -> String:
|
||||
var configured := _configured_client_count()
|
||||
if configured == 0:
|
||||
if not _client_status_refresh_has_completed():
|
||||
return "Server connected · checking AI client configuration"
|
||||
return "Server connected · no AI client configured"
|
||||
if configured == 1:
|
||||
return "Server connected · 1 AI client configured"
|
||||
return "Server connected · %d AI clients configured" % configured
|
||||
|
||||
|
||||
func _on_install_uv() -> void:
|
||||
match OS.get_name():
|
||||
"Windows":
|
||||
@@ -1548,28 +1724,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 +1790,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 +1860,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 +1915,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)
|
||||
|
||||
@@ -1976,8 +2191,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 +2385,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 +2399,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 +2526,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 +2543,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:
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="Godot AI"
|
||||
description="MCP server and AI tools for Godot"
|
||||
author="Godot AI"
|
||||
version="2.7.6"
|
||||
version="2.8.1"
|
||||
script="plugin.gd"
|
||||
|
||||
@@ -233,14 +233,14 @@ 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)
|
||||
add_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 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)
|
||||
|
||||
@@ -81,9 +81,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()
|
||||
|
||||
@@ -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,7 @@ extends McpStructuredLogRing
|
||||
const MAX_LINES := 2000
|
||||
|
||||
var _run_id := ""
|
||||
var _run_seq := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -24,17 +24,22 @@ func _init() -> void:
|
||||
|
||||
|
||||
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
|
||||
var entry := {
|
||||
"source": "game",
|
||||
"level": _coerce_level(level),
|
||||
"text": text,
|
||||
"run_id": _run_id,
|
||||
}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_append_entry(entry)
|
||||
|
||||
|
||||
## 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()
|
||||
return _run_id
|
||||
|
||||
@@ -43,8 +48,38 @@ func run_id() -> String:
|
||||
return _run_id
|
||||
|
||||
|
||||
static func _generate_run_id() -> String:
|
||||
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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -314,6 +314,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("/")
|
||||
@@ -457,7 +459,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 +485,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()
|
||||
|
||||
Reference in New Issue
Block a user