更新godot ai 到最新版本
This commit is contained in:
@@ -132,6 +132,9 @@ static func prepare_major_upgrade_endpoints(from_version: String, to_version: St
|
|||||||
return {"ok": false, "error": "EditorSettings is unavailable"}
|
return {"ok": false, "error": "EditorSettings is unavailable"}
|
||||||
var legacy_http := _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
|
var legacy_http := _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
|
||||||
var legacy_ws := _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT)
|
var legacy_ws := _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT)
|
||||||
|
var occupancy := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
||||||
|
if OS.get_name() == "Windows" and not occupancy.known:
|
||||||
|
return {"ok": false, "error": "Windows could not query listening ports; retry endpoint selection."}
|
||||||
var selected: Array[int] = []
|
var selected: Array[int] = []
|
||||||
var candidate := legacy_http + 1 if legacy_http < MAX_PORT else MIN_PORT
|
var candidate := legacy_http + 1 if legacy_http < MAX_PORT else MIN_PORT
|
||||||
candidate = WindowsPortReservation.suggest_non_excluded_port(candidate, MAX_PORT - candidate + 1, MAX_PORT)
|
candidate = WindowsPortReservation.suggest_non_excluded_port(candidate, MAX_PORT - candidate + 1, MAX_PORT)
|
||||||
@@ -140,17 +143,32 @@ static func prepare_major_upgrade_endpoints(from_version: String, to_version: St
|
|||||||
candidate = WindowsPortReservation.suggest_non_excluded_port_from_output(reserved_output, candidate, MAX_PORT - candidate + 1, MAX_PORT)
|
candidate = WindowsPortReservation.suggest_non_excluded_port_from_output(reserved_output, candidate, MAX_PORT - candidate + 1, MAX_PORT)
|
||||||
if candidate < MIN_PORT or candidate > MAX_PORT:
|
if candidate < MIN_PORT or candidate > MAX_PORT:
|
||||||
break
|
break
|
||||||
if candidate not in [legacy_http, legacy_ws] and not selected.has(candidate) and PortResolver.can_bind_local_port(candidate) and not PortResolver.is_port_in_use(candidate):
|
if candidate not in [legacy_http, legacy_ws] and not selected.has(candidate) and PortResolver.can_bind_local_port(candidate):
|
||||||
selected.append(candidate)
|
var available: bool
|
||||||
if selected.size() == 2:
|
if OS.get_name() == "Windows":
|
||||||
break
|
available = PortResolver.windows_port_occupancy(candidate, occupancy) == PortResolver.PortOccupancy.FREE
|
||||||
|
else:
|
||||||
|
available = not PortResolver.is_port_in_use(candidate)
|
||||||
|
if available:
|
||||||
|
selected.append(candidate)
|
||||||
|
if selected.size() == 2:
|
||||||
|
break
|
||||||
candidate += 1
|
candidate += 1
|
||||||
if candidate > MAX_PORT:
|
if candidate > MAX_PORT:
|
||||||
candidate = MIN_PORT
|
candidate = MIN_PORT
|
||||||
if selected.size() != 2:
|
if selected.size() != 2:
|
||||||
return {"ok": false, "error": "No independent HTTP/WebSocket port pair was available for this major upgrade."}
|
return {"ok": false, "error": "No independent HTTP/WebSocket port pair was available for this major upgrade."}
|
||||||
|
if OS.get_name() == "Windows":
|
||||||
|
occupancy = PortResolver.windows_listener_snapshot()
|
||||||
|
if not occupancy.known:
|
||||||
|
return {"ok": false, "error": "Windows could not query listening ports; retry endpoint selection."}
|
||||||
for port in selected:
|
for port in selected:
|
||||||
if WindowsPortReservation.parse_excluded(reserved_output, port) or not PortResolver.can_bind_local_port(port) or PortResolver.is_port_in_use(port):
|
var available: bool
|
||||||
|
if OS.get_name() == "Windows":
|
||||||
|
available = PortResolver.windows_port_occupancy(port, occupancy) == PortResolver.PortOccupancy.FREE
|
||||||
|
else:
|
||||||
|
available = not PortResolver.is_port_in_use(port)
|
||||||
|
if WindowsPortReservation.parse_excluded(reserved_output, port) or not PortResolver.can_bind_local_port(port) or not available:
|
||||||
return {"ok": false, "error": "The selected upgrade port %d became unavailable; retry endpoint selection." % port}
|
return {"ok": false, "error": "The selected upgrade port %d became unavailable; retry endpoint selection." % port}
|
||||||
var pair := {"http_port": selected[0], "ws_port": selected[1]}
|
var pair := {"http_port": selected[0], "ws_port": selected[1]}
|
||||||
es.set_setting(SETTING_V4_ENDPOINT_PORTS, pair)
|
es.set_setting(SETTING_V4_ENDPOINT_PORTS, pair)
|
||||||
@@ -535,13 +553,17 @@ static func allow_hosts() -> String:
|
|||||||
## "free" honest on macOS/Linux, where the reservation table is empty but the
|
## "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
|
## next port up may still be occupied — the same suggestion feeds the dock
|
||||||
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
|
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
|
||||||
## log line. Falls back to the clamped candidate if nothing in the window
|
## log line. Returns zero when discovery fails or no candidate is
|
||||||
## clears both checks (caller surfaces it as a best-effort hint; the user can
|
## available. A TOCTOU window remains
|
||||||
## 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
|
## 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
|
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
|
||||||
## stall on a pathological run of occupied ports.
|
## stall on a pathological run of occupied ports.
|
||||||
static func suggest_free_port(start: int, span: int = 2048) -> int:
|
static func suggest_free_port(start: int, span: int = 2048, occupancy: Dictionary = {}) -> int:
|
||||||
|
if OS.get_name() == "Windows":
|
||||||
|
if occupancy.is_empty():
|
||||||
|
occupancy = PortResolver.windows_listener_snapshot()
|
||||||
|
if not occupancy.known:
|
||||||
|
return 0
|
||||||
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
|
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
|
||||||
var limit := mini(candidate + span - 1, MAX_PORT)
|
var limit := mini(candidate + span - 1, MAX_PORT)
|
||||||
var p := candidate
|
var p := candidate
|
||||||
@@ -556,10 +578,11 @@ static func suggest_free_port(start: int, span: int = 2048) -> int:
|
|||||||
break
|
break
|
||||||
p = not_reserved
|
p = not_reserved
|
||||||
probes += 1
|
probes += 1
|
||||||
if PortResolver.can_bind_local_port(p):
|
var occupied := OS.get_name() == "Windows" and PortResolver.windows_port_occupancy(p, occupancy) != PortResolver.PortOccupancy.FREE
|
||||||
|
if not occupied and PortResolver.can_bind_local_port(p):
|
||||||
return p
|
return p
|
||||||
p += 1
|
p += 1
|
||||||
return candidate
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# --- Client operations (string id) ---------------------------------------
|
# --- Client operations (string id) ---------------------------------------
|
||||||
@@ -752,6 +775,11 @@ static func warm_env_snapshot(endpoint_policy: Dictionary = {}) -> void:
|
|||||||
for env_name in [client.get("config_file_env"), client.get("config_home_env")]:
|
for env_name in [client.get("config_file_env"), client.get("config_home_env")]:
|
||||||
if env_name is String and not env_name.is_empty() and not extras.has(env_name):
|
if env_name is String and not env_name.is_empty() and not extras.has(env_name):
|
||||||
extras.append(env_name)
|
extras.append(env_name)
|
||||||
|
var scope_envs: Variant = client.get("config_scope_envs")
|
||||||
|
if scope_envs is PackedStringArray:
|
||||||
|
for env_name in scope_envs:
|
||||||
|
if not env_name.is_empty() and not extras.has(env_name):
|
||||||
|
extras.append(env_name)
|
||||||
McpPathTemplate.warm_env_snapshot(extras)
|
McpPathTemplate.warm_env_snapshot(extras)
|
||||||
UvResolution.warm_environment()
|
UvResolution.warm_environment()
|
||||||
_editor_setting_lookup(MODE_OVERRIDE_SETTING)
|
_editor_setting_lookup(MODE_OVERRIDE_SETTING)
|
||||||
|
|||||||
@@ -89,16 +89,55 @@ var path_template: Dictionary = {}
|
|||||||
## `path_template` remains the fallback.
|
## `path_template` remains the fallback.
|
||||||
var config_path_candidates: Dictionary = {}
|
var config_path_candidates: Dictionary = {}
|
||||||
|
|
||||||
## Optional global JSON config files merged by the client from lowest to highest
|
## Optional global JSON config files merged by the client. Unlike
|
||||||
## precedence. Unlike `config_path_candidates`, every existing file participates;
|
## `config_path_candidates`, every existing file participates. By default the
|
||||||
## Configure updates the effective last definition, status verifies it, and
|
## client keeps the LAST definition: Configure updates the effective last
|
||||||
## Remove clears every global definition transactionally.
|
## definition, status verifies it, and Remove clears every global definition
|
||||||
|
## transactionally. Clients that keep the FIRST definition instead set
|
||||||
|
## `config_merge_first_wins` and declare tiers in the client's read order;
|
||||||
|
## the folds then target the first tier defining the server, so a write
|
||||||
|
## updates the file that is already effective and a fresh entry lands on
|
||||||
|
## tiers[0] — the client's own primary file.
|
||||||
var config_merge_path_templates: Dictionary = {}
|
var config_merge_path_templates: Dictionary = {}
|
||||||
## Project-relative tiers that may override the global files. Their root is the
|
## Project-relative tiers that may override the global files. Their root is the
|
||||||
## external client's working directory, which the Godot process cannot know.
|
## external client's working directory, which the Godot process cannot know.
|
||||||
## The strategy checks plausible roots and fails closed instead of mutating them.
|
## The strategy checks plausible roots and fails closed instead of mutating them.
|
||||||
var config_merge_project_paths: PackedStringArray = PackedStringArray()
|
var config_merge_project_paths: PackedStringArray = PackedStringArray()
|
||||||
|
|
||||||
|
## True when the client keeps the FIRST definition of a duplicated server and
|
||||||
|
## ignores later ones (omp-native: project `.omp/mcp.json` precedes
|
||||||
|
## `.omp/.mcp.json`, then the user scope's `mcp.json` precedes `.mcp.json`).
|
||||||
|
## Flips every merge-tier fold — Configure target, status verification, and
|
||||||
|
## the manual/Open-Reveal target resolution — from last-definition-wins to
|
||||||
|
## first. With tiers declared in read order, Configure updates the tier that
|
||||||
|
## already owns the server instead of creating a higher-priority entry that
|
||||||
|
## shadows a lower tier's user state (#1085).
|
||||||
|
var config_merge_first_wins: bool = false
|
||||||
|
|
||||||
|
## Directory-glob templates whose matches make the user-scope config
|
||||||
|
## destination unknowable from the editor (omp: `~/.omp/profiles/*`). One `*`
|
||||||
|
## per directory segment, expanded by `McpPathTemplate.expand_path_candidates`.
|
||||||
|
## Any existing match means a named profile may own the user scope: the active
|
||||||
|
## profile is chosen per client launch and never persisted where the editor can
|
||||||
|
## read it, so Configure and Remove fail closed with the matched paths and
|
||||||
|
## status reports the ambiguity instead of green-lighting the default file.
|
||||||
|
var config_scope_globs: PackedStringArray = PackedStringArray()
|
||||||
|
|
||||||
|
## Environment names that make the effective config destination unknown.
|
||||||
|
## Configure, Remove and status refuse while any is set; values are not reported.
|
||||||
|
var config_scope_envs: PackedStringArray = PackedStringArray()
|
||||||
|
|
||||||
|
## Top-level server-name denylist in the first declared merge tier (the default
|
||||||
|
## primary file), independent of which tier owns the entry. Configure and status
|
||||||
|
## refuse a suppressed name; Remove preserves this user state. Empty disables it.
|
||||||
|
var config_denylist_key: String = ""
|
||||||
|
|
||||||
|
## Effective-entry enable toggle and primary force-enable list. Boolean false
|
||||||
|
## and strings "false"/"0" suppress the entry unless the allowlist names it.
|
||||||
|
## The denylist still wins. Empty keys disable these checks.
|
||||||
|
var config_enabled_key: String = ""
|
||||||
|
var config_allowlist_key: String = ""
|
||||||
|
|
||||||
## De-duplicate persistent path-ambiguity warnings across recurring status
|
## De-duplicate persistent path-ambiguity warnings across recurring status
|
||||||
## refreshes. The actionable message still returns on every resolution; only
|
## refreshes. The actionable message still returns on every resolution; only
|
||||||
## the editor-console echo is single-shot until the ambiguity clears/changes.
|
## the editor-console echo is single-shot until the ambiguity clears/changes.
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ static func _configure_merged(
|
|||||||
var launch_error := command_launch_error(client, launch)
|
var launch_error := command_launch_error(client, launch)
|
||||||
if not launch_error.is_empty():
|
if not launch_error.is_empty():
|
||||||
return {"status": "error", "message": launch_error}
|
return {"status": "error", "message": launch_error}
|
||||||
|
## A matched `config_scope_globs` directory (omp named profiles) means the
|
||||||
|
## user scope may live elsewhere for the running client; writing the
|
||||||
|
## default tiers would false-succeed (#1085).
|
||||||
|
var scope_error := _scope_ambiguity_error(client)
|
||||||
|
if not scope_error.is_empty():
|
||||||
|
return {"status": "error", "message": scope_error}
|
||||||
var project := _load_project_definitions(client, server_name, project_roots)
|
var project := _load_project_definitions(client, server_name, project_roots)
|
||||||
if not project.get("ok", false):
|
if not project.get("ok", false):
|
||||||
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
|
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
|
||||||
@@ -74,16 +80,26 @@ static func _configure_merged(
|
|||||||
var tiers: Array = loaded.get("tiers", [])
|
var tiers: Array = loaded.get("tiers", [])
|
||||||
if tiers.is_empty():
|
if tiers.is_empty():
|
||||||
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
||||||
|
var denylist_error := _denylist_error(client, tiers, server_name)
|
||||||
|
if not denylist_error.is_empty():
|
||||||
|
return {"status": "error", "message": denylist_error}
|
||||||
var target_index := 0
|
var target_index := 0
|
||||||
for index in range(tiers.size()):
|
for index in range(tiers.size()):
|
||||||
var config: Dictionary = tiers[index]["data"]
|
var config: Dictionary = tiers[index]["data"]
|
||||||
var holder := _walk_path(config, select_server_key_path(config, client))
|
var holder := _walk_path(config, select_server_key_path(config, client))
|
||||||
if holder is Dictionary and holder.has(server_name):
|
if holder is Dictionary and holder.has(server_name):
|
||||||
target_index = index
|
target_index = index
|
||||||
|
## First-definition-wins clients keep the earliest defining tier;
|
||||||
|
## later tiers are dead, so updating one of those changes nothing.
|
||||||
|
if _first_wins(client):
|
||||||
|
break
|
||||||
var tier: Dictionary = tiers[target_index]
|
var tier: Dictionary = tiers[target_index]
|
||||||
var config: Dictionary = tier["data"]
|
var config: Dictionary = tier["data"]
|
||||||
var holder := _ensure_path(config, select_server_key_path(config, client))
|
var holder := _ensure_path(config, select_server_key_path(config, client))
|
||||||
var existing: Variant = holder.get(server_name, null)
|
var existing: Variant = holder.get(server_name, null)
|
||||||
|
var disabled_error := _disabled_entry_error(client, tiers, server_name, existing, str(tier["path"]))
|
||||||
|
if not disabled_error.is_empty():
|
||||||
|
return {"status": "error", "message": disabled_error}
|
||||||
holder[server_name] = build_entry(client, server_url, existing, launch)
|
holder[server_name] = build_entry(client, server_url, existing, launch)
|
||||||
var path := str(tier["path"])
|
var path := str(tier["path"])
|
||||||
# F5: refuse to re-serialize a tier whose parsed integers above 2^53 would
|
# F5: refuse to re-serialize a tier whose parsed integers above 2^53 would
|
||||||
@@ -148,7 +164,7 @@ static func check_status_details(
|
|||||||
return _entry_status_details(client, holder[server_name], server_url, launch)
|
return _entry_status_details(client, holder[server_name], server_url, launch)
|
||||||
|
|
||||||
|
|
||||||
## Verify the effective last definition after applying the client's merge order.
|
## Verify the effective definition after applying the client's merge order.
|
||||||
static func _check_status_merged(
|
static func _check_status_merged(
|
||||||
client: McpClient,
|
client: McpClient,
|
||||||
server_name: String,
|
server_name: String,
|
||||||
@@ -156,39 +172,60 @@ static func _check_status_merged(
|
|||||||
launch: Dictionary,
|
launch: Dictionary,
|
||||||
project_roots: PackedStringArray,
|
project_roots: PackedStringArray,
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
|
var scope_error := _scope_ambiguity_error(client)
|
||||||
|
if not scope_error.is_empty():
|
||||||
|
return {"status": McpClient.Status.ERROR, "error_msg": scope_error}
|
||||||
var allow_comments := _status_allows_comments(client)
|
var allow_comments := _status_allows_comments(client)
|
||||||
var loaded := _load_merge_tiers(client, allow_comments)
|
var loaded := _load_merge_tiers(client, allow_comments)
|
||||||
if not loaded.get("ok", false):
|
if not loaded.get("ok", false):
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))}
|
return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))}
|
||||||
var effective: Variant = null
|
var effective: Variant = null
|
||||||
|
var effective_path := ""
|
||||||
for tier in loaded.get("tiers", []):
|
for tier in loaded.get("tiers", []):
|
||||||
var config: Dictionary = tier["data"]
|
var config: Dictionary = tier["data"]
|
||||||
var holder := _walk_path(config, select_server_key_path(config, client))
|
var holder := _walk_path(config, select_server_key_path(config, client))
|
||||||
if holder is Dictionary and holder.has(server_name):
|
if holder is Dictionary and holder.has(server_name):
|
||||||
effective = holder[server_name]
|
effective = holder[server_name]
|
||||||
|
effective_path = str(tier["path"])
|
||||||
|
## First-definition-wins: the earliest defining tier is the one the
|
||||||
|
## client actually reads; stop instead of overwriting with a dead one.
|
||||||
|
if _first_wins(client):
|
||||||
|
break
|
||||||
var project := _load_project_definitions(client, server_name, project_roots, allow_comments)
|
var project := _load_project_definitions(client, server_name, project_roots, allow_comments)
|
||||||
if not project.get("ok", false):
|
if not project.get("ok", false):
|
||||||
return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))}
|
return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))}
|
||||||
var project_tiers: Array = project.get("tiers", [])
|
var project_tiers: Array = project.get("tiers", [])
|
||||||
|
if effective != null or not project_tiers.is_empty():
|
||||||
|
var denylist_error := _denylist_error(client, loaded.get("tiers", []), server_name)
|
||||||
|
if not denylist_error.is_empty():
|
||||||
|
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": denylist_error}
|
||||||
if not project_tiers.is_empty():
|
if not project_tiers.is_empty():
|
||||||
# Last-definition-wins mirrors the global-tier fold above and how
|
# The project tier that drives the client's effective config: the
|
||||||
# pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead
|
# latest for last-wins clients (pi-codemode-mcp merges project tiers
|
||||||
# once a later one defines the same server, so an early-stale entry
|
# that way; earlier tiers are dead once a later one defines the same
|
||||||
# doesn't make Pi's effective config drift (codex-review finding F2).
|
# server — codex-review finding F2), the earliest for first-wins
|
||||||
# Pass `[latest]` to `_project_override_message` so the error names only
|
# clients (omp reads `.omp/mcp.json` before `.omp/.mcp.json`). Pass
|
||||||
# the file the user actually has to edit.
|
# `[effective_tier]` to `_project_override_message` so the error names
|
||||||
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
|
# only the file the user actually has to edit.
|
||||||
var details := _entry_status_details(client, latest["entry"], server_url, launch)
|
var effective_index := 0 if _first_wins(client) else project_tiers.size() - 1
|
||||||
|
var effective_tier: Dictionary = project_tiers[effective_index]
|
||||||
|
var disabled_error := _disabled_entry_error(client, loaded.get("tiers", []), server_name, effective_tier["entry"], str(effective_tier["path"]))
|
||||||
|
if not disabled_error.is_empty():
|
||||||
|
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": disabled_error}
|
||||||
|
var details := _entry_status_details(client, effective_tier["entry"], server_url, launch)
|
||||||
if details.get("status") != McpClient.Status.CONFIGURED:
|
if details.get("status") != McpClient.Status.CONFIGURED:
|
||||||
## Keep `owned` from the effective entry: the post-update migration
|
## Keep `owned` from the effective entry: the post-update migration
|
||||||
## decides from it whether this mismatch is ours to repin.
|
## decides from it whether this mismatch is ours to repin.
|
||||||
var mismatch := details.duplicate()
|
var mismatch := details.duplicate()
|
||||||
mismatch["status"] = McpClient.Status.CONFIGURED_MISMATCH
|
mismatch["status"] = McpClient.Status.CONFIGURED_MISMATCH
|
||||||
mismatch["error_msg"] = _project_override_message([latest], "update or remove", client.display_name, server_name)
|
mismatch["error_msg"] = _project_override_message([effective_tier], "update or remove", client.display_name, server_name)
|
||||||
return mismatch
|
return mismatch
|
||||||
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
|
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
|
||||||
if effective == null:
|
if effective == null:
|
||||||
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
|
||||||
|
var disabled_error := _disabled_entry_error(client, loaded.get("tiers", []), server_name, effective, effective_path)
|
||||||
|
if not disabled_error.is_empty():
|
||||||
|
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": disabled_error}
|
||||||
return _entry_status_details(client, effective, server_url, launch)
|
return _entry_status_details(client, effective, server_url, launch)
|
||||||
|
|
||||||
|
|
||||||
@@ -247,6 +284,12 @@ static func remove(
|
|||||||
static func _remove_merged(
|
static func _remove_merged(
|
||||||
client: McpClient, server_name: String, project_roots: PackedStringArray
|
client: McpClient, server_name: String, project_roots: PackedStringArray
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
|
## Scope ambiguity (omp named profiles) blocks Remove too: clearing the
|
||||||
|
## default files while a profile reads its own would report a success the
|
||||||
|
## running client never observes (#1085).
|
||||||
|
var scope_error := _scope_ambiguity_error(client)
|
||||||
|
if not scope_error.is_empty():
|
||||||
|
return {"status": "error", "message": scope_error}
|
||||||
var project := _load_project_definitions(client, server_name, project_roots)
|
var project := _load_project_definitions(client, server_name, project_roots)
|
||||||
if not project.get("ok", false):
|
if not project.get("ok", false):
|
||||||
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
|
return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
|
||||||
@@ -633,6 +676,63 @@ static func _uses_merge_tiers(client: McpClient) -> bool:
|
|||||||
var templates = client.get("config_merge_path_templates")
|
var templates = client.get("config_merge_path_templates")
|
||||||
return templates is Dictionary and not templates.is_empty()
|
return templates is Dictionary and not templates.is_empty()
|
||||||
|
|
||||||
|
## True when the client resolves duplicate definitions first-wins (see
|
||||||
|
## `McpClient.config_merge_first_wins`). Dynamic get keeps a mixed-snapshot
|
||||||
|
## self-update parse-safe against an older McpClient base (#398/#736).
|
||||||
|
static func _first_wins(client: McpClient) -> bool:
|
||||||
|
return bool(client.get("config_merge_first_wins"))
|
||||||
|
|
||||||
|
|
||||||
|
## Fail-closed gate for clients whose user scope can be relocated by something
|
||||||
|
## the editor cannot observe (omp named profiles). Any directory matching a
|
||||||
|
## declared `config_scope_globs` template means the effective destination is
|
||||||
|
## ambiguous. Declared relocation environment names also cause refusal.
|
||||||
|
static func _scope_ambiguity_error(client: McpClient) -> String:
|
||||||
|
var env_names: Variant = client.get("config_scope_envs")
|
||||||
|
if env_names is PackedStringArray:
|
||||||
|
for env_name in env_names:
|
||||||
|
if not McpPathTemplate.env_lookup(env_name).is_empty():
|
||||||
|
return "%s is set and may relocate %s configuration. Confirm the active client config path and edit the entry manually; default-profile files were not changed." % [env_name, client.display_name]
|
||||||
|
var globs: Variant = client.get("config_scope_globs")
|
||||||
|
if not (globs is PackedStringArray):
|
||||||
|
return ""
|
||||||
|
var found := PackedStringArray()
|
||||||
|
for template in globs:
|
||||||
|
for candidate in McpPathTemplate.expand_path_candidates(String(template)):
|
||||||
|
if DirAccess.dir_exists_absolute(candidate) and not found.has(candidate):
|
||||||
|
found.append(candidate)
|
||||||
|
if found.is_empty():
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
"%s named profiles found at %s. A named profile reads only its own agent config and the active profile is chosen per %s launch, so this editor cannot resolve the effective destination; edit the entry manually."
|
||||||
|
% [client.display_name, ", ".join(found), client.display_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
static func _disabled_entry_error(client: McpClient, tiers: Array, server_name: String, entry: Variant, path: String) -> String:
|
||||||
|
if client.config_enabled_key.is_empty() or not (entry is Dictionary):
|
||||||
|
return ""
|
||||||
|
var enabled: Variant = entry.get(client.config_enabled_key)
|
||||||
|
var disabled: bool = (enabled is bool and not enabled) or (enabled is String and enabled.to_lower() in ["false", "0"])
|
||||||
|
if not disabled:
|
||||||
|
return ""
|
||||||
|
if not client.config_allowlist_key.is_empty() and not tiers.is_empty():
|
||||||
|
var allowed: Variant = tiers[0]["data"].get(client.config_allowlist_key)
|
||||||
|
if allowed is Array and allowed.has(server_name):
|
||||||
|
return ""
|
||||||
|
return "%s is disabled by %s in %s. Re-enable it in %s before configuring; all files were left unchanged." % [server_name, client.config_enabled_key, path, client.display_name]
|
||||||
|
|
||||||
|
|
||||||
|
static func _denylist_error(client: McpClient, tiers: Array, server_name: String) -> String:
|
||||||
|
var key: Variant = client.get("config_denylist_key")
|
||||||
|
if not (key is String) or String(key).is_empty() or tiers.is_empty():
|
||||||
|
return ""
|
||||||
|
var primary: Dictionary = tiers[0]
|
||||||
|
var denylist: Variant = primary["data"].get(key, null)
|
||||||
|
if denylist is Array and denylist.has(server_name):
|
||||||
|
return "%s is disabled by %s in %s. Review that primary config manually before configuring; all files were left unchanged." % [server_name, key, primary["path"]]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
## Resolve the global config tiers in the client's documented merge order.
|
## Resolve the global config tiers in the client's documented merge order.
|
||||||
static func _merge_paths(client: McpClient) -> PackedStringArray:
|
static func _merge_paths(client: McpClient) -> PackedStringArray:
|
||||||
@@ -765,11 +865,12 @@ static func _write_transaction(writes: Array[Dictionary]) -> Dictionary:
|
|||||||
## land on the file that actually drives Pi's effective config.
|
## land on the file that actually drives Pi's effective config.
|
||||||
##
|
##
|
||||||
## Resolution order (codex round 3, F-3-4):
|
## Resolution order (codex round 3, F-3-4):
|
||||||
## 1. Latest project tier containing the entry — F2 last-wins means the
|
## 1. The project tier that wins the client's fold — the latest of
|
||||||
## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`).
|
## `.pi/mcp.json` and `.mcp.json` for last-wins clients, the earliest
|
||||||
## 2. Latest global tier containing the entry — already iterated in
|
## for first-wins clients (matches `_check_status_merged`).
|
||||||
## merge order, last-iterated-wins (same loop pattern as
|
## 2. The global tier that wins the client's fold — for last-wins clients
|
||||||
## `manual_target_details` lines 660-665).
|
## the last-iterated match, for first-wins clients the first match
|
||||||
|
## (tiers arrive in the client's read order).
|
||||||
## 3. "" when no tier has the entry — caller decides what fallback
|
## 3. "" when no tier has the entry — caller decides what fallback
|
||||||
## (`path_template`) to use.
|
## (`path_template`) to use.
|
||||||
##
|
##
|
||||||
@@ -789,24 +890,28 @@ static func authoritative_tier_path(
|
|||||||
return ""
|
return ""
|
||||||
var project_tiers: Array = project.get("tiers", [])
|
var project_tiers: Array = project.get("tiers", [])
|
||||||
if not project_tiers.is_empty():
|
if not project_tiers.is_empty():
|
||||||
# F2 last-wins: latest project tier is authoritative. When there are
|
# The project tier that drives the user-visible status (F2: latest for
|
||||||
# multiple project tiers, the user-visible status is driven by the
|
# last-wins clients, earliest for first-wins ones), so the Open/Reveal
|
||||||
# latest, so the Open/Reveal buttons should send them there too.
|
# buttons send the user to the file they actually have to edit.
|
||||||
var latest: Dictionary = project_tiers[project_tiers.size() - 1]
|
var effective_index := 0 if _first_wins(client) else project_tiers.size() - 1
|
||||||
return str(latest.get("path", ""))
|
var effective_tier: Dictionary = project_tiers[effective_index]
|
||||||
|
return str(effective_tier.get("path", ""))
|
||||||
var loaded := _load_merge_tiers(client, allow_comments)
|
var loaded := _load_merge_tiers(client, allow_comments)
|
||||||
if not bool(loaded.get("ok", false)):
|
if not bool(loaded.get("ok", false)):
|
||||||
return ""
|
return ""
|
||||||
var tiers: Array = loaded.get("tiers", [])
|
var tiers: Array = loaded.get("tiers", [])
|
||||||
# Iterate in priority order, overwrite `selected_path` each time we
|
# Iterate in the client's read order. Last-wins clients overwrite
|
||||||
# find a tier containing the entry. With Pi's merge path order
|
# `selected_path` on every match (Pi's [mcp.json, .mcp.json] order then
|
||||||
# [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`.
|
# leaves the higher-precedence `.mcp.json`); first-wins clients stop at
|
||||||
|
# the first match, which is the file the client actually reads.
|
||||||
var selected_path: String = ""
|
var selected_path: String = ""
|
||||||
for tier in tiers:
|
for tier in tiers:
|
||||||
var data: Dictionary = tier.get("data", {})
|
var data: Dictionary = tier.get("data", {})
|
||||||
var holder: Variant = _walk_path(data, select_server_key_path(data, client))
|
var holder: Variant = _walk_path(data, select_server_key_path(data, client))
|
||||||
if holder is Dictionary and holder.has(server_name):
|
if holder is Dictionary and holder.has(server_name):
|
||||||
selected_path = str(tier.get("path", ""))
|
selected_path = str(tier.get("path", ""))
|
||||||
|
if _first_wins(client):
|
||||||
|
break
|
||||||
return selected_path
|
return selected_path
|
||||||
|
|
||||||
|
|
||||||
@@ -845,6 +950,10 @@ static func manual_target_details(
|
|||||||
var holder := _walk_path(config, select_server_key_path(config, client))
|
var holder := _walk_path(config, select_server_key_path(config, client))
|
||||||
if holder is Dictionary and holder.has(server_name):
|
if holder is Dictionary and holder.has(server_name):
|
||||||
selected = tier
|
selected = tier
|
||||||
|
## First-wins clients read the earliest defining tier; later
|
||||||
|
## matches are dead and must not redirect the manual flow.
|
||||||
|
if _first_wins(client):
|
||||||
|
break
|
||||||
if selected != null:
|
if selected != null:
|
||||||
var config: Dictionary = selected["data"]
|
var config: Dictionary = selected["data"]
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -199,7 +199,10 @@ static func _build_json(
|
|||||||
target = {"path": resolved_path, "key_path": client.server_key_path}
|
target = {"path": resolved_path, "key_path": client.server_key_path}
|
||||||
var target_path := str(target.get("path", resolved_path))
|
var target_path := str(target.get("path", resolved_path))
|
||||||
var key_path: PackedStringArray = target.get("key_path", client.server_key_path)
|
var key_path: PackedStringArray = target.get("key_path", client.server_key_path)
|
||||||
var key := key_path[0] if key_path.size() > 0 else "mcpServers"
|
## Name the whole server-map path. A nested map (ZCode's `mcp.servers`) must
|
||||||
|
## not collapse to its first segment, or the instructions place the entry a
|
||||||
|
## level too high for the client parser to find.
|
||||||
|
var key := ".".join(key_path) if key_path.size() > 0 else "mcpServers"
|
||||||
if client.command_shape != McpClient.CommandShape.NONE:
|
if client.command_shape != McpClient.CommandShape.NONE:
|
||||||
var lines: Array[String] = []
|
var lines: Array[String] = []
|
||||||
var launch_error := McpJsonStrategy.command_launch_error(client, launch)
|
var launch_error := McpJsonStrategy.command_launch_error(client, launch)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const _CLIENT_SCRIPT_PATHS := [
|
|||||||
"res://addons/godot_ai/clients/hermes.gd",
|
"res://addons/godot_ai/clients/hermes.gd",
|
||||||
"res://addons/godot_ai/clients/pi.gd",
|
"res://addons/godot_ai/clients/pi.gd",
|
||||||
"res://addons/godot_ai/clients/deepseek_harness.gd",
|
"res://addons/godot_ai/clients/deepseek_harness.gd",
|
||||||
|
"res://addons/godot_ai/clients/zcode.gd",
|
||||||
|
"res://addons/godot_ai/clients/omp.gd",
|
||||||
]
|
]
|
||||||
|
|
||||||
static var _instances: Array[McpClient] = []
|
static var _instances: Array[McpClient] = []
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
@tool
|
||||||
|
extends McpClient
|
||||||
|
|
||||||
|
## Oh My Pi (omp): https://github.com/can1357/oh-my-pi
|
||||||
|
## omp keeps the FIRST definition of a duplicated server: project
|
||||||
|
## `.omp/mcp.json` shadows `.omp/.mcp.json`, then the active user scope's
|
||||||
|
## `mcp.json` shadows its `.mcp.json`. The tiers below are declared in that
|
||||||
|
## read order with `config_merge_first_wins`, so Configure updates the file
|
||||||
|
## that already owns the server instead of shadowing a compatibility
|
||||||
|
## entry's user state (#1085). omp never writes the compatibility files
|
||||||
|
## itself; this adapter updates an existing compatibility entry in place.
|
||||||
|
## A fresh entry is created only on tiers[0].
|
||||||
|
##
|
||||||
|
## The user scope can be relocated per launch (`omp --profile`,
|
||||||
|
## OMP_PROFILE/PI_PROFILE). `config_scope_globs` fails Configure/Remove
|
||||||
|
## closed while any named profile exists, because the active profile is
|
||||||
|
## chosen at client launch and is not persisted anywhere the editor can
|
||||||
|
## read — status reports the ambiguity instead of green-lighting the
|
||||||
|
## default file.
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
id = "omp"
|
||||||
|
display_name = "Oh My Pi"
|
||||||
|
config_type = "json"
|
||||||
|
path_template = {
|
||||||
|
"unix": "~/.omp/agent/mcp.json",
|
||||||
|
"windows": "$USERPROFILE/.omp/agent/mcp.json",
|
||||||
|
}
|
||||||
|
## Declared in omp's read order (primary first, compatibility second);
|
||||||
|
## `_first_wins` folds stop at the first tier defining the server, so a
|
||||||
|
## write updates the effective file and a fresh entry lands on the
|
||||||
|
## primary. Status still verifies against every existing tier.
|
||||||
|
config_merge_path_templates = {
|
||||||
|
"unix": PackedStringArray([
|
||||||
|
"~/.omp/agent/mcp.json",
|
||||||
|
"~/.omp/agent/.mcp.json",
|
||||||
|
]),
|
||||||
|
"windows": PackedStringArray([
|
||||||
|
"$USERPROFILE/.omp/agent/mcp.json",
|
||||||
|
"$USERPROFILE/.omp/agent/.mcp.json",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
config_merge_project_paths = PackedStringArray([".omp/mcp.json", ".omp/.mcp.json"])
|
||||||
|
config_merge_first_wins = true
|
||||||
|
config_scope_globs = PackedStringArray(["~/.omp/profiles/*"])
|
||||||
|
config_scope_envs = PackedStringArray(["PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"])
|
||||||
|
## The primary user file owns this override even for a compatibility entry.
|
||||||
|
config_denylist_key = "disabledServers"
|
||||||
|
config_enabled_key = "enabled"
|
||||||
|
config_allowlist_key = "enabledServers"
|
||||||
|
server_key_path = PackedStringArray(["mcpServers"])
|
||||||
|
command_shape = McpClient.CommandShape.FLAT
|
||||||
|
command_legacy_keys = PackedStringArray(["url", "headers", "type"])
|
||||||
|
## Seeded only on a fresh entry; reconfigure preserves the effective
|
||||||
|
## entry's values. OMP_MCP_TIMEOUT_MS may override this per-server
|
||||||
|
## timeout.
|
||||||
|
command_initial_fields = {"enabled": true, "timeout": 300000}
|
||||||
|
command_timeout_fields = PackedStringArray(["timeout"])
|
||||||
|
command_user_fields = PackedStringArray(["enabled", "timeout", "env", "cwd"])
|
||||||
|
## omp creates `~/.omp/agent` on first launch (agent.db and friends)
|
||||||
|
## before any MCP server is configured, so the directory is the honest
|
||||||
|
## install signal — the config leaf may not exist yet.
|
||||||
|
detect_paths = PackedStringArray(["~/.omp/agent"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b1daig0jwxvfb
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@tool
|
||||||
|
extends McpClient
|
||||||
|
|
||||||
|
## ZCode (Z.AI's GLM coding agent): https://zcode.z.ai/en/docs/mcp-services
|
||||||
|
## User-scope MCP servers live in `~/.zcode/cli/config.json` under the nested
|
||||||
|
## `mcp.servers` map; the workspace scope is `<project>/.zcode/config.json`,
|
||||||
|
## which this descriptor does not target because the client's working directory
|
||||||
|
## is unknown to the editor. Entries are flat command/args/env, and
|
||||||
|
## `type: "stdio"` is what the settings form, the bundled plugin manifest, and
|
||||||
|
## working third-party integrations write - it also repins a stale
|
||||||
|
## `type: "http"` left on a hand-added remote entry. `enable` (singular) is
|
||||||
|
## ZCode's documented user-state key (absence means enabled), so it is
|
||||||
|
## preserved rather than written.
|
||||||
|
##
|
||||||
|
## Manual-only: adding any `.zcode` server makes ZCode skip `~/.agents/mcp.json`
|
||||||
|
## for that scope entirely (documented `.zcode`-first rule, no merging). An
|
||||||
|
## automatic write could therefore silently disable every server a user keeps in
|
||||||
|
## the fallback, so Configure and Remove only render the manual entry; moving
|
||||||
|
## those entries into the native file stays the user's explicit choice.
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
id = "zcode"
|
||||||
|
display_name = "ZCode"
|
||||||
|
config_type = "json"
|
||||||
|
## A native write would shadow `~/.agents/mcp.json`; see the class comment.
|
||||||
|
automatic_config_edits = false
|
||||||
|
path_template = {
|
||||||
|
"unix": "~/.zcode/cli/config.json",
|
||||||
|
"windows": "$USERPROFILE/.zcode/cli/config.json",
|
||||||
|
}
|
||||||
|
server_key_path = PackedStringArray(["mcp", "servers"])
|
||||||
|
command_shape = McpClient.CommandShape.FLAT
|
||||||
|
command_transport_key = "type"
|
||||||
|
command_transport_value = "stdio"
|
||||||
|
command_legacy_keys = PackedStringArray(["url", "headers"])
|
||||||
|
command_user_fields = PackedStringArray(["env", "enable"])
|
||||||
|
## ZCode creates `~/.zcode` on first launch; the config leaf may not exist
|
||||||
|
## yet, so the directory is the installed signal.
|
||||||
|
detect_paths = PackedStringArray(["~/.zcode"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b0xpusa88vykn
|
||||||
@@ -109,13 +109,7 @@ var _packet_spillover_total := 0
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_session_id = _make_session_id(ProjectSettings.globalize_path("res://"))
|
_session_id = _make_session_id(ProjectSettings.globalize_path("res://"))
|
||||||
## Increase outbound buffer for large messages (e.g. screenshot base64).
|
_configure_peer_buffers(_peer)
|
||||||
## Default is 64 KB; screenshots can be several MB.
|
|
||||||
_peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
|
|
||||||
## Stay under the pre-auth byte ceiling until the server proof validates;
|
|
||||||
## only then may the peer allocate the normal 4 MiB command buffer.
|
|
||||||
_peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES
|
|
||||||
_peer.max_queued_packets = MAX_QUEUED_PACKETS
|
|
||||||
if connect_blocked:
|
if connect_blocked:
|
||||||
_log_blocked_notice_once()
|
_log_blocked_notice_once()
|
||||||
set_process(false)
|
set_process(false)
|
||||||
@@ -375,12 +369,22 @@ func _attempt_reconnect() -> void:
|
|||||||
## a quiet reconnect loop after the Python server restarts.
|
## a quiet reconnect loop after the Python server restarts.
|
||||||
_peer = WebSocketPeer.new()
|
_peer = WebSocketPeer.new()
|
||||||
_preopen_failure_logged_for_peer = false
|
_preopen_failure_logged_for_peer = false
|
||||||
_peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
|
_configure_peer_buffers(_peer)
|
||||||
_peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES
|
|
||||||
_peer.max_queued_packets = MAX_QUEUED_PACKETS
|
|
||||||
_connect_to_server()
|
_connect_to_server()
|
||||||
|
|
||||||
|
|
||||||
|
## WSLPeer fixes its receive limit during the WebSocket handshake; changing
|
||||||
|
## this property after the server proof cannot raise that limit (#1056).
|
||||||
|
## The peer buffers use the command budget even before authentication.
|
||||||
|
## `_handle_message` separately enforces the smaller handshake message limit.
|
||||||
|
static func _configure_peer_buffers(peer: WebSocketPeer) -> void:
|
||||||
|
## Outbound: screenshots are several MB of base64 (default is 64 KiB).
|
||||||
|
peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
|
||||||
|
## Inbound: the normal 4 MiB command ceiling, applied before connecting.
|
||||||
|
peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
|
||||||
|
peer.max_queued_packets = MAX_QUEUED_PACKETS
|
||||||
|
|
||||||
|
|
||||||
func pause() -> void:
|
func pause() -> void:
|
||||||
_pause_depth += 1
|
_pause_depth += 1
|
||||||
|
|
||||||
@@ -647,7 +651,6 @@ func _handle_auth_challenge(parsed: Dictionary) -> void:
|
|||||||
_server_nonce = str(parsed["server_nonce"])
|
_server_nonce = str(parsed["server_nonce"])
|
||||||
_challenged_server_version = str(parsed["server_version"])
|
_challenged_server_version = str(parsed["server_version"])
|
||||||
_server_verified = true
|
_server_verified = true
|
||||||
_peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
|
|
||||||
_last_readiness = get_readiness()
|
_last_readiness = get_readiness()
|
||||||
var response := _build_auth_response()
|
var response := _build_auth_response()
|
||||||
if response.is_empty() or not _send_json(response, true):
|
if response.is_empty() or not _send_json(response, true):
|
||||||
|
|||||||
@@ -80,17 +80,39 @@ static func _port_spinbox(value: int, tooltip: String) -> SpinBox:
|
|||||||
## carry it keep working. Note that this OVERWRITES unsaved user input — fine
|
## carry it keep working. Note that this OVERWRITES unsaved user input — fine
|
||||||
## in practice because the dock only calls this on `server_status`
|
## in practice because the dock only calls this on `server_status`
|
||||||
## transitions (`if server_status == _last_server_status: return`).
|
## transitions (`if server_status == _last_server_status: return`).
|
||||||
func seed_suggested_ports(conflict_port := 0) -> void:
|
func seed_suggested_ports(conflict_port := 0, occupancy: Dictionary = {}) -> void:
|
||||||
if _spinbox == null:
|
if _spinbox == null:
|
||||||
return
|
return
|
||||||
|
if OS.get_name() == "Windows" and occupancy.is_empty():
|
||||||
|
occupancy = PortResolver.windows_listener_snapshot()
|
||||||
var http := ClientConfigurator.http_port()
|
var http := ClientConfigurator.http_port()
|
||||||
var ws := ClientConfigurator.ws_port()
|
var ws := ClientConfigurator.ws_port()
|
||||||
if conflict_port == http or bool(port_in_use_probe.call(http)):
|
var http_occupied: bool
|
||||||
http = ClientConfigurator.suggest_free_port(http + 1)
|
var ws_occupied: bool
|
||||||
if conflict_port == ws or ws == http or bool(port_in_use_probe.call(ws)):
|
if OS.get_name() == "Windows":
|
||||||
ws = ClientConfigurator.suggest_free_port(ws + 1)
|
http_occupied = PortResolver.windows_port_occupancy(http, occupancy) != PortResolver.PortOccupancy.FREE
|
||||||
if ws == http:
|
ws_occupied = PortResolver.windows_port_occupancy(ws, occupancy) != PortResolver.PortOccupancy.FREE
|
||||||
ws = ClientConfigurator.suggest_free_port(ws + 1)
|
else:
|
||||||
|
http_occupied = bool(port_in_use_probe.call(http))
|
||||||
|
ws_occupied = bool(port_in_use_probe.call(ws))
|
||||||
|
var unavailable := false
|
||||||
|
if conflict_port == http or http_occupied:
|
||||||
|
var suggested := ClientConfigurator.suggest_free_port(http + 1, 2048, occupancy)
|
||||||
|
if suggested > 0:
|
||||||
|
http = suggested
|
||||||
|
else:
|
||||||
|
unavailable = true
|
||||||
|
if conflict_port == ws or ws == http or ws_occupied:
|
||||||
|
var suggested := ClientConfigurator.suggest_free_port(ws + 1, 2048, occupancy)
|
||||||
|
if suggested == http:
|
||||||
|
suggested = ClientConfigurator.suggest_free_port(suggested + 1, 2048, occupancy)
|
||||||
|
if suggested > 0:
|
||||||
|
ws = suggested
|
||||||
|
else:
|
||||||
|
unavailable = true
|
||||||
|
var detail := "Automatic port selection is unavailable. Choose ports manually or retry." if unavailable else ""
|
||||||
|
_spinbox.tooltip_text = detail if unavailable else "Effective HTTP port"
|
||||||
|
_ws_spinbox.tooltip_text = detail if unavailable else "Effective WebSocket port"
|
||||||
_spinbox.value = http
|
_spinbox.value = http
|
||||||
_ws_spinbox.value = ws
|
_ws_spinbox.value = ws
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,15 @@ static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
|
|||||||
return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
|
return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
|
||||||
|
|
||||||
|
|
||||||
|
## A rotation slerp can consume: all components finite and unit length within
|
||||||
|
## float32 precision. Guards the normalization path against a scaled result
|
||||||
|
## that is zero, infinite, or NaN.
|
||||||
|
static func _is_usable_rotation(quat: Quaternion) -> bool:
|
||||||
|
if not is_finite(quat.x) or not is_finite(quat.y) or not is_finite(quat.z) or not is_finite(quat.w):
|
||||||
|
return false
|
||||||
|
return absf(quat.length() - 1.0) <= 0.001
|
||||||
|
|
||||||
|
|
||||||
## Coerce a single value to the given Godot variant type. Returns
|
## Coerce a single value to the given Godot variant type. Returns
|
||||||
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
|
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
|
||||||
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
|
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
|
||||||
@@ -386,6 +395,65 @@ static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -
|
|||||||
TYPE_BOOL:
|
TYPE_BOOL:
|
||||||
if value is int or value is float or value is bool:
|
if value is int or value is float or value is bool:
|
||||||
return {"ok": bool(value)}
|
return {"ok": bool(value)}
|
||||||
|
TYPE_VECTOR3I:
|
||||||
|
var v3i = McpJsonValues.parse_vector3i(value)
|
||||||
|
if v3i != null:
|
||||||
|
return {"ok": v3i}
|
||||||
|
return {"error": "Cannot coerce value to Vector3i for property '%s' (expected {x,y,z}, [x,y,z], or Vector3i; components must be finite and within int32 range)" % prop_name}
|
||||||
|
TYPE_QUATERNION:
|
||||||
|
var quat = McpJsonValues.parse_quaternion(value)
|
||||||
|
if quat == null:
|
||||||
|
return {"error": "Cannot coerce value to Quaternion for property '%s' (expected {x,y,z,w}, [x,y,z,w], or Quaternion; components must be finite)" % prop_name}
|
||||||
|
## Rotation contract at the animation boundary: AnimationPlayer
|
||||||
|
## slerps quaternions, and a non-unit one makes
|
||||||
|
## value_track_interpolate emit a normalization error and return
|
||||||
|
## identity. Normalize valid nonzero rotations so stored tracks are
|
||||||
|
## usable; only an exactly zero-length quaternion is refused.
|
||||||
|
## Scale by the largest component before measuring: components are
|
||||||
|
## float32, so squaring one near the type's maximum overflows to
|
||||||
|
## INF (normalized() then returns (0,0,0,0)), while squaring a tiny
|
||||||
|
## nonzero one underflows to zero and misreads as zero-length.
|
||||||
|
var largest := maxf(maxf(absf(quat.x), absf(quat.y)), maxf(absf(quat.z), absf(quat.w)))
|
||||||
|
if largest == 0.0:
|
||||||
|
return {"error": "Cannot coerce value to Quaternion for property '%s': zero-length quaternion is not a rotation" % prop_name}
|
||||||
|
var normalized := Quaternion(
|
||||||
|
quat.x / largest, quat.y / largest, quat.z / largest, quat.w / largest
|
||||||
|
).normalized()
|
||||||
|
## Validate the result instead of trusting the arithmetic: an
|
||||||
|
## unusable rotation must fail here, not reach slerp as zero.
|
||||||
|
if not _is_usable_rotation(normalized):
|
||||||
|
return {"error": "Cannot coerce value to Quaternion for property '%s': components cannot be normalized to a usable rotation" % prop_name}
|
||||||
|
return {"ok": normalized}
|
||||||
|
TYPE_BASIS:
|
||||||
|
var basis = McpJsonValues.parse_basis(value)
|
||||||
|
if basis != null:
|
||||||
|
return {"ok": basis}
|
||||||
|
return {"error": "Cannot coerce value to Basis for property '%s' (expected {x,y,z} axis vectors, a 3x3 nested array, or Basis)" % prop_name}
|
||||||
|
TYPE_TRANSFORM3D:
|
||||||
|
var xform = McpJsonValues.parse_transform3d(value)
|
||||||
|
if xform != null:
|
||||||
|
return {"ok": xform}
|
||||||
|
return {"error": "Cannot coerce value to Transform3D for property '%s' (expected {basis,origin}, {position,rotation_degrees?,scale?}, or Transform3D)" % prop_name}
|
||||||
|
TYPE_RECT2:
|
||||||
|
var rect = McpJsonValues.parse_rect2(value)
|
||||||
|
if rect != null:
|
||||||
|
return {"ok": rect}
|
||||||
|
return {"error": "Cannot coerce value to Rect2 for property '%s' (expected {position,size}, [x,y,w,h], or Rect2)" % prop_name}
|
||||||
|
TYPE_AABB:
|
||||||
|
var aabb = McpJsonValues.parse_aabb(value)
|
||||||
|
if aabb != null:
|
||||||
|
return {"ok": aabb}
|
||||||
|
return {"error": "Cannot coerce value to AABB for property '%s' (expected {position,size}, [x,y,z,w,h,d], or AABB)" % prop_name}
|
||||||
|
TYPE_NODE_PATH:
|
||||||
|
var node_path = McpJsonValues.parse_node_path(value)
|
||||||
|
if node_path != null:
|
||||||
|
return {"ok": node_path}
|
||||||
|
return {"error": "Cannot coerce value to NodePath for property '%s' (expected a string path)" % prop_name}
|
||||||
|
TYPE_STRING_NAME:
|
||||||
|
var string_name = McpJsonValues.parse_string_name(value)
|
||||||
|
if string_name != null:
|
||||||
|
return {"ok": string_name}
|
||||||
|
return {"error": "Cannot coerce value to StringName for property '%s' (expected a string)" % prop_name}
|
||||||
return {"ok": value}
|
return {"ok": value}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ func _take_screenshot_impl(params: Dictionary) -> Dictionary:
|
|||||||
"viewport_2d",
|
"viewport_2d",
|
||||||
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
|
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
|
||||||
)
|
)
|
||||||
return _finalize_image(image_2d, "viewport_2d", max_resolution)
|
return _finalize_image(image_2d, "viewport_2d", max_resolution, viewport.use_hdr_2d)
|
||||||
_:
|
_:
|
||||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
|
||||||
|
|
||||||
@@ -848,9 +848,9 @@ func _find_current_camera_3d(root: Node) -> Camera3D:
|
|||||||
return first
|
return first
|
||||||
|
|
||||||
|
|
||||||
func _finalize_image(image: Image, source: String, max_resolution: int) -> Dictionary:
|
func _finalize_image(image: Image, source: String, max_resolution: int, use_hdr_2d := false) -> Dictionary:
|
||||||
## Shared with the game-process copy in runtime/game_helper.gd (#716).
|
## Shared with the game-process copy in runtime/game_helper.gd (#716).
|
||||||
var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution)
|
var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution, use_hdr_2d)
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|||||||
@@ -318,3 +318,19 @@ static func _settle_scan(
|
|||||||
"reason": "Filesystem scan is an editor operation",
|
"reason": "Filesystem scan is an editor operation",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
## Resource-group mutations use read-only deferred discovery before disk effects.
|
||||||
|
const Mutation := preload("res://addons/godot_ai/handlers/filesystem_mutation.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func move_file(params: Dictionary) -> Dictionary:
|
||||||
|
return Mutation.start(_connection, params, "move")
|
||||||
|
|
||||||
|
|
||||||
|
func rename_file(params: Dictionary) -> Dictionary:
|
||||||
|
return Mutation.start(_connection, params, "rename")
|
||||||
|
|
||||||
|
|
||||||
|
func remove_file(params: Dictionary) -> Dictionary:
|
||||||
|
return Mutation.start(_connection, params, "remove")
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const Errors := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
|
||||||
|
const PLUGIN_TREE := "res://addons/godot_ai"
|
||||||
|
const SIDECARS := [".uid", ".import"]
|
||||||
|
const OWNER_EXTENSIONS := ["gd", "cs", "gdshader", "gdshaderinc", "tscn", "tres", "scn", "res"]
|
||||||
|
const MAX_ENTRIES := 10000
|
||||||
|
const MAX_FILE_BYTES := 256 * 1024
|
||||||
|
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
|
||||||
|
const MAX_TARGETS := 256
|
||||||
|
const BUDGET_USEC := 2000
|
||||||
|
const DEADLINE_MSEC := 25000
|
||||||
|
static var _busy := false
|
||||||
|
|
||||||
|
var _alive: Callable
|
||||||
|
var _deadline := 0
|
||||||
|
var _yield_at := 0
|
||||||
|
var _bytes := 0
|
||||||
|
var _directories: Dictionary = {}
|
||||||
|
var _fingerprints: Dictionary = {}
|
||||||
|
var _fault := ""
|
||||||
|
var _io: Callable
|
||||||
|
|
||||||
|
## Dispatch stays synchronous; the retained job owns work across frame yields.
|
||||||
|
static func start(connection: McpConnection, params: Dictionary, operation: String) -> Dictionary:
|
||||||
|
var request_id := str(params.get("_request_id", ""))
|
||||||
|
if connection == null or request_id.is_empty():
|
||||||
|
return _unchanged(
|
||||||
|
"Filesystem mutations require a direct deferred command; call filesystem_manage outside batch_execute")
|
||||||
|
_finish(connection, request_id, params.duplicate(true), operation)
|
||||||
|
return {"_deferred": true, "_deferred_timeout_ms": 30000}
|
||||||
|
|
||||||
|
|
||||||
|
static func _finish(connection: McpConnection, request_id: String, params: Dictionary, operation: String) -> void:
|
||||||
|
var work := ScriptWork.begin("filesystem_" + operation)
|
||||||
|
var tree := connection.get_tree()
|
||||||
|
if tree == null:
|
||||||
|
ScriptWork.finish(work)
|
||||||
|
return
|
||||||
|
# Register the deferred response before the job can test its ownership.
|
||||||
|
await tree.process_frame
|
||||||
|
var job = load("res://addons/godot_ai/handlers/filesystem_mutation.gd").new()
|
||||||
|
var active := func() -> bool:
|
||||||
|
return is_instance_valid(connection) and connection.dispatcher != null and connection.dispatcher.has_pending_deferred_response(request_id)
|
||||||
|
var result: Dictionary = await job.run(params, operation, active)
|
||||||
|
if active.call():
|
||||||
|
connection.send_deferred_response(request_id, result)
|
||||||
|
ScriptWork.finish(work)
|
||||||
|
|
||||||
|
|
||||||
|
## Discovery is cancellable and read-only. Once disk effects start, this owner
|
||||||
|
## finishes or reconciles them before releasing the mutation slot.
|
||||||
|
func run(params: Dictionary, operation: String, active: Callable = Callable()) -> Dictionary:
|
||||||
|
if _busy:
|
||||||
|
return _unchanged("Another filesystem mutation is still running")
|
||||||
|
_busy = true
|
||||||
|
_alive = active
|
||||||
|
_deadline = Time.get_ticks_msec() + DEADLINE_MSEC
|
||||||
|
_yield_at = Time.get_ticks_usec()
|
||||||
|
var result := await _run(params, operation)
|
||||||
|
_busy = false
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _run(params: Dictionary, operation: String) -> Dictionary:
|
||||||
|
var source := str(params.get("path", "")).trim_suffix("/")
|
||||||
|
var error = McpPathValidator.path_error(source, "path", true)
|
||||||
|
if error != null:
|
||||||
|
return error
|
||||||
|
source = source.simplify_path()
|
||||||
|
if source == "res:/" or source == "res://" or _reserved(source):
|
||||||
|
return _unchanged("Cannot mutate the project root, metadata, or loaded plugin tree")
|
||||||
|
if _is_sidecar(source):
|
||||||
|
return _unchanged("Mutate the resource, not its .uid or .import sidecar")
|
||||||
|
var directory := DirAccess.dir_exists_absolute(source)
|
||||||
|
if not directory and not FileAccess.file_exists(source):
|
||||||
|
return Errors.make(Errors.RESOURCE_NOT_FOUND, "File or folder not found: %s" % source)
|
||||||
|
var destination := str(params.get("new_path", "")).trim_suffix("/")
|
||||||
|
if operation == "rename":
|
||||||
|
var name := str(params.get("new_name", ""))
|
||||||
|
if name.is_empty() or "/" in name or "\\" in name or name in [".", ".."]:
|
||||||
|
return _unchanged("new_name must be a nonempty bare name without path separators")
|
||||||
|
destination = source.get_base_dir().path_join(name)
|
||||||
|
if operation != "remove":
|
||||||
|
error = McpPathValidator.path_error(destination, "new_path", true)
|
||||||
|
if error != null:
|
||||||
|
return error
|
||||||
|
destination = destination.simplify_path()
|
||||||
|
if _reserved(destination):
|
||||||
|
return _unchanged("Cannot move into project metadata or the loaded plugin tree")
|
||||||
|
if source == destination or (directory and (destination + "/").to_lower().begins_with(source.to_lower() + "/")):
|
||||||
|
return _unchanged("Cannot move a path onto itself or into its own subtree")
|
||||||
|
if not DirAccess.dir_exists_absolute(destination.get_base_dir()):
|
||||||
|
return _unchanged("Destination parent must already exist: %s" % destination.get_base_dir())
|
||||||
|
if directory and operation == "remove" and bool(params.get("permanent", false)):
|
||||||
|
return _unchanged("Permanent directory removal is unsupported; use the default OS trash")
|
||||||
|
if not _confined_without_links(source) or (operation != "remove" and not _confined_without_links(destination)):
|
||||||
|
return _unchanged(_fault)
|
||||||
|
var files: Array[String] = []
|
||||||
|
if not await _collect_tree("res://", files):
|
||||||
|
return _unchanged(_fault)
|
||||||
|
if directory and not _directories.has(source):
|
||||||
|
return _unchanged("Source directory was excluded or has noncanonical casing; nothing changed")
|
||||||
|
var targets := {}
|
||||||
|
for path in files:
|
||||||
|
if not _is_sidecar(path) and (path == source or (directory and path.begins_with(source + "/"))):
|
||||||
|
targets[path] = {"uid": ResourceLoader.get_resource_uid(path)}
|
||||||
|
if not directory and not targets.has(source):
|
||||||
|
return _unchanged("Source was excluded from discovery; nothing changed")
|
||||||
|
if targets.size() > MAX_TARGETS:
|
||||||
|
return _unchanged("Mutation exceeds the %d-resource limit; use smaller groups" % MAX_TARGETS)
|
||||||
|
for path in files:
|
||||||
|
if path == source or (directory and path.begins_with(source + "/")) or path in [source + ".uid", source + ".import"]:
|
||||||
|
# Owner-extension targets are read and fingerprinted by the owner
|
||||||
|
# pass below; reading them here too would charge the byte budget twice.
|
||||||
|
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
if await _read_bounded(path) == null:
|
||||||
|
return _unchanged(_fault)
|
||||||
|
var owners := []
|
||||||
|
for path in files:
|
||||||
|
if path.get_extension().to_lower() not in OWNER_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
var content: Variant = await _read_bounded(path)
|
||||||
|
if content == null:
|
||||||
|
return _unchanged(_fault)
|
||||||
|
# One call per owner: _references lowercases and regex-scans the whole
|
||||||
|
# file, so a per-target loop repeated that up to MAX_TARGETS times.
|
||||||
|
var hits := await _references(path, content, targets)
|
||||||
|
if not _fault.is_empty():
|
||||||
|
return _unchanged(_fault)
|
||||||
|
if not await _yield_if_needed():
|
||||||
|
return _unchanged(_fault)
|
||||||
|
if not hits.is_empty() and not (operation == "remove" and targets.has(path)):
|
||||||
|
owners.append({"path": path, "references": hits})
|
||||||
|
var editor_guard := _editor_state_guard(targets)
|
||||||
|
if not editor_guard.is_empty():
|
||||||
|
return editor_guard
|
||||||
|
for owner in owners:
|
||||||
|
for hit in owner.references:
|
||||||
|
if operation == "remove":
|
||||||
|
if not bool(params.get("force", false)):
|
||||||
|
return _unchanged("Resource is still referenced; repair references or use force=true for known dangling references", {"referenced_by": owners})
|
||||||
|
elif hit.kind != "uid":
|
||||||
|
return _unchanged("Move requires dependency path rewrites, which are unsupported; update references or use preserved uid:// references first", {"referenced_by": owners})
|
||||||
|
var group: Array[Dictionary] = [{"from": source, "to": destination}]
|
||||||
|
if not directory:
|
||||||
|
for suffix in SIDECARS:
|
||||||
|
if FileAccess.file_exists(source + suffix):
|
||||||
|
group.append({"from": source + suffix, "to": destination + suffix})
|
||||||
|
for item in group:
|
||||||
|
if not _confined_without_links(item.from):
|
||||||
|
return _unchanged(_fault)
|
||||||
|
if operation != "remove" and not _destination_available(item.from, item.to):
|
||||||
|
return _unchanged(_fault)
|
||||||
|
# Re-read owner bytes and directory entry censuses after the yielding plan.
|
||||||
|
if not await _revalidate():
|
||||||
|
return _unchanged(_fault)
|
||||||
|
if not _active():
|
||||||
|
return _unchanged("Filesystem request expired before mutation; nothing changed")
|
||||||
|
for item in group:
|
||||||
|
if not _confined_without_links(item.from) or (operation != "remove" and not _destination_available(item.from, item.to)):
|
||||||
|
return _unchanged(_fault)
|
||||||
|
# Revalidation yields. Recheck mutable editor authority after the last
|
||||||
|
# callback/yield, with no suspension between this guard and disk effects.
|
||||||
|
editor_guard = _editor_state_guard(targets)
|
||||||
|
if not editor_guard.is_empty():
|
||||||
|
return editor_guard
|
||||||
|
if operation == "remove":
|
||||||
|
return _remove(source, directory, group, targets, owners, bool(params.get("permanent", false)))
|
||||||
|
return _move(source, destination, directory, group, targets)
|
||||||
|
|
||||||
|
|
||||||
|
func _active() -> bool:
|
||||||
|
return Time.get_ticks_msec() < _deadline and (not _alive.is_valid() or bool(_alive.call()))
|
||||||
|
|
||||||
|
|
||||||
|
func _yield_if_needed() -> bool:
|
||||||
|
if not _active():
|
||||||
|
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
|
||||||
|
return false
|
||||||
|
if Time.get_ticks_usec() - _yield_at >= BUDGET_USEC:
|
||||||
|
await (Engine.get_main_loop() as SceneTree).process_frame
|
||||||
|
_yield_at = Time.get_ticks_usec()
|
||||||
|
if not _active():
|
||||||
|
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _collect_tree(root: String, files: Array[String]) -> bool:
|
||||||
|
var pending: Array[String] = [root]
|
||||||
|
var count := 0
|
||||||
|
while not pending.is_empty():
|
||||||
|
var path: String = pending.pop_back()
|
||||||
|
var dir := DirAccess.open(path)
|
||||||
|
if dir == null:
|
||||||
|
_fault = "Cannot inspect directory: %s" % path
|
||||||
|
return false
|
||||||
|
dir.include_hidden = true
|
||||||
|
if dir.list_dir_begin() != OK:
|
||||||
|
_fault = "Cannot list directory: %s" % path
|
||||||
|
return false
|
||||||
|
var entries: Array[String] = []
|
||||||
|
var name := dir.get_next()
|
||||||
|
while not name.is_empty():
|
||||||
|
if name not in [".", ".."]:
|
||||||
|
var child := path.path_join(name)
|
||||||
|
entries.append(name)
|
||||||
|
# Engine metadata, VCS internals and the immutable loaded plugin
|
||||||
|
# implementation are outside the user-resource owner scan.
|
||||||
|
if child not in ["res://.godot", "res://.git", PLUGIN_TREE]:
|
||||||
|
if not dir.has_method("is_link") or bool(dir.call("is_link", child)):
|
||||||
|
_fault = "Cannot establish ownership through a linked entry: %s" % child
|
||||||
|
dir.list_dir_end()
|
||||||
|
return false
|
||||||
|
if dir.current_is_dir():
|
||||||
|
pending.append(child)
|
||||||
|
else:
|
||||||
|
files.append(child)
|
||||||
|
count += 1
|
||||||
|
if count > MAX_ENTRIES:
|
||||||
|
_fault = "Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES
|
||||||
|
dir.list_dir_end()
|
||||||
|
return false
|
||||||
|
if not await _yield_if_needed():
|
||||||
|
dir.list_dir_end()
|
||||||
|
return false
|
||||||
|
name = dir.get_next()
|
||||||
|
dir.list_dir_end()
|
||||||
|
entries.sort()
|
||||||
|
_directories[path] = entries
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _read_bounded(path: String) -> Variant:
|
||||||
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null or file.get_length() > MAX_FILE_BYTES:
|
||||||
|
_fault = "Cannot safely read owner %s (unreadable or exceeds %d bytes)" % [path, MAX_FILE_BYTES]
|
||||||
|
return null
|
||||||
|
var bytes := PackedByteArray()
|
||||||
|
var length := file.get_length()
|
||||||
|
while file.get_position() < length:
|
||||||
|
var part := file.get_buffer(mini(16384, length - file.get_position()))
|
||||||
|
if part.is_empty():
|
||||||
|
_fault = "Short read while inspecting %s" % path
|
||||||
|
return null
|
||||||
|
bytes.append_array(part)
|
||||||
|
_bytes += part.size()
|
||||||
|
if _bytes > MAX_TOTAL_BYTES:
|
||||||
|
_fault = "Filesystem discovery exceeds its byte budget; nothing changed"
|
||||||
|
return null
|
||||||
|
if not await _yield_if_needed():
|
||||||
|
return null
|
||||||
|
file.close()
|
||||||
|
var digest := HashingContext.new()
|
||||||
|
digest.start(HashingContext.HASH_SHA256)
|
||||||
|
digest.update(bytes)
|
||||||
|
_fingerprints[path] = digest.finish()
|
||||||
|
return bytes
|
||||||
|
|
||||||
|
|
||||||
|
func _references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
|
||||||
|
var hits: Array[Dictionary] = []
|
||||||
|
if path.get_extension().to_lower() in ["res", "scn"]:
|
||||||
|
_fault = "Binary dependency discovery is unsupported for safe mutations: %s" % path
|
||||||
|
return hits
|
||||||
|
# Compare paths conservatively across case-insensitive project volumes.
|
||||||
|
# A case-only false positive on a sensitive volume is a safe refusal.
|
||||||
|
var text := bytes.get_string_from_utf8().to_lower()
|
||||||
|
# Relative preload/load literals are path dependencies too. Never rewrite
|
||||||
|
# source text: recognizing one causes an unchanged refusal below.
|
||||||
|
var literals := RegEx.new()
|
||||||
|
literals.compile("[\"']([^\"'\\n]+)[\"']")
|
||||||
|
var relative_paths: Dictionary = {}
|
||||||
|
for literal in literals.search_all(text):
|
||||||
|
var value: String = literal.get_string(1)
|
||||||
|
if not value.contains(":"):
|
||||||
|
relative_paths[path.get_base_dir().path_join(value).simplify_path().to_lower()] = true
|
||||||
|
# ResourceLoader.load uses the project root for bare paths.
|
||||||
|
relative_paths["res://".path_join(value).simplify_path().to_lower()] = true
|
||||||
|
for target: String in targets:
|
||||||
|
# Keep cancellation and frame progress even when a path match continues.
|
||||||
|
if not await _yield_if_needed():
|
||||||
|
return hits
|
||||||
|
var target_text := target.to_lower()
|
||||||
|
var uid: int = targets[target].uid
|
||||||
|
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
|
||||||
|
var uses_uid := not uid_text.is_empty() and (("\"%s\"" % uid_text) in text or ("'%s'" % uid_text) in text)
|
||||||
|
if relative_paths.has(target_text):
|
||||||
|
hits.append({"path": target, "kind": "path"})
|
||||||
|
continue
|
||||||
|
var uses_path := ("\"%s\"" % target_text) in text or ("'%s'" % target_text) in text
|
||||||
|
if uses_path:
|
||||||
|
# A stored ext_resource fallback path is safe only with this exact
|
||||||
|
# target's preserved UID on the same header. Other strings block.
|
||||||
|
var only_uid_headers := path.get_extension().to_lower() in ["tscn", "tres"] and not (("'%s'" % target_text) in text)
|
||||||
|
for line in text.split("\n"):
|
||||||
|
if ("\"%s\"" % target_text) in line:
|
||||||
|
only_uid_headers = only_uid_headers and line.strip_edges().begins_with("[ext_resource ") and not uid_text.is_empty() and ("uid=\"%s\"" % uid_text) in line
|
||||||
|
if not only_uid_headers:
|
||||||
|
hits.append({"path": target, "kind": "path"})
|
||||||
|
continue
|
||||||
|
uses_uid = true
|
||||||
|
if uses_uid:
|
||||||
|
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
|
||||||
|
_fault = "UID mapping is not authoritative for %s" % target
|
||||||
|
return hits
|
||||||
|
hits.append({"path": target, "kind": "uid"})
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
func _editor_state_guard(targets: Dictionary) -> Dictionary:
|
||||||
|
var settings := _settings_references(targets)
|
||||||
|
if not settings.is_empty():
|
||||||
|
return _unchanged("Project settings reference this resource; update those settings explicitly before mutating it", {"settings": settings})
|
||||||
|
for scene in EditorInterface.get_open_scenes():
|
||||||
|
if targets.has(scene):
|
||||||
|
return _unchanged("Close affected scene tabs before mutating their files", {"scene": scene})
|
||||||
|
for target: String in targets:
|
||||||
|
var uid: int = targets[target].uid
|
||||||
|
if uid != ResourceUID.INVALID_ID and (not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target):
|
||||||
|
return _unchanged("UID mapping is no longer authoritative for %s; nothing changed" % target)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
func _settings_references(targets: Dictionary) -> Array[String]:
|
||||||
|
var settings: Array[String] = []
|
||||||
|
for prop in ProjectSettings.get_property_list():
|
||||||
|
var name := str(prop.name)
|
||||||
|
var value: Variant = ProjectSettings.get_setting(name)
|
||||||
|
if not value is String:
|
||||||
|
continue
|
||||||
|
var path: String = value.trim_prefix("*")
|
||||||
|
if path.begins_with("uid://"):
|
||||||
|
var uid := ResourceUID.text_to_id(path)
|
||||||
|
path = ResourceUID.get_id_path(uid) if ResourceUID.has_id(uid) else path
|
||||||
|
if targets.has(path):
|
||||||
|
settings.append(name)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
func _revalidate() -> bool:
|
||||||
|
var original_directories := _directories.duplicate(true)
|
||||||
|
_directories.clear()
|
||||||
|
var files: Array[String] = []
|
||||||
|
if not await _collect_tree("res://", files):
|
||||||
|
return false
|
||||||
|
if _directories != original_directories:
|
||||||
|
_fault = "Project entries changed during discovery; retry after changes settle"
|
||||||
|
return false
|
||||||
|
var original := _fingerprints.duplicate(true)
|
||||||
|
for path in original:
|
||||||
|
if await _read_bounded(path) == null:
|
||||||
|
return false
|
||||||
|
if _fingerprints[path] != original[path]:
|
||||||
|
_fault = "Owner changed during discovery: %s" % path
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _confined_without_links(path: String) -> bool:
|
||||||
|
var dir := DirAccess.open("res://")
|
||||||
|
if dir == null or not dir.has_method("is_link"):
|
||||||
|
_fault = "Filesystem link inspection is unavailable; refusing mutation"
|
||||||
|
return false
|
||||||
|
var current := "res://"
|
||||||
|
for component in path.trim_prefix("res://").split("/"):
|
||||||
|
current = current.path_join(component)
|
||||||
|
if bool(dir.call("is_link", current)):
|
||||||
|
_fault = "Linked filesystem paths cannot be mutated: %s" % current
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _destination_available(source: String, destination: String) -> bool:
|
||||||
|
if not _confined_without_links(destination):
|
||||||
|
return false
|
||||||
|
if not FileAccess.file_exists(destination) and not DirAccess.dir_exists_absolute(destination):
|
||||||
|
return true
|
||||||
|
var parent := source.get_base_dir()
|
||||||
|
if parent == destination.get_base_dir() and source.to_lower() == destination.to_lower():
|
||||||
|
var dir := DirAccess.open(parent)
|
||||||
|
if dir != null and dir.has_method("is_equivalent"):
|
||||||
|
dir.include_hidden = true
|
||||||
|
var entries := dir.get_files() + dir.get_directories()
|
||||||
|
if entries.has(source.get_file()) and not entries.has(destination.get_file()) and bool(dir.call("is_equivalent", source, destination)):
|
||||||
|
return true
|
||||||
|
_fault = "Destination already exists or case-only identity is ambiguous: %s" % destination
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _rename(source: String, destination: String) -> int:
|
||||||
|
if _io.is_valid():
|
||||||
|
return int(_io.call("rename", source, destination))
|
||||||
|
return DirAccess.rename_absolute(source, destination)
|
||||||
|
|
||||||
|
|
||||||
|
func _move(source: String, destination: String, directory: bool, group: Array[Dictionary], targets: Dictionary) -> Dictionary:
|
||||||
|
var applied: Array[Dictionary] = []
|
||||||
|
for item in group:
|
||||||
|
var code := _rename(item.from, item.to)
|
||||||
|
if code != OK:
|
||||||
|
var unrestored: Array[Dictionary] = []
|
||||||
|
for index in range(applied.size() - 1, -1, -1):
|
||||||
|
var prior := applied[index]
|
||||||
|
if _rename(prior.to, prior.from) != OK:
|
||||||
|
unrestored.append(prior)
|
||||||
|
var outcome := "unchanged" if applied.is_empty() else ("rolled_back" if unrestored.is_empty() else "partial")
|
||||||
|
if not unrestored.is_empty():
|
||||||
|
_reconcile_move(targets, source, destination, false)
|
||||||
|
return _failure("Move failed: %s" % error_string(code), outcome, {"applied": applied, "unrestored": unrestored})
|
||||||
|
applied.append(item)
|
||||||
|
_reconcile_move(targets, source, destination)
|
||||||
|
var moved: Array[Dictionary] = []
|
||||||
|
for old_path: String in targets:
|
||||||
|
moved.append({"from": old_path, "to": destination + old_path.trim_prefix(source)})
|
||||||
|
return {"data": {"path": source, "new_path": destination, "kind": "directory" if directory else "file",
|
||||||
|
"moved": moved, "moved_count": moved.size(), "uids_updated": _uid_count(targets), "outcome": "committed", "scan_required": directory,
|
||||||
|
"dependencies_updated": [], "project_settings_updated": [], "warnings": [], "undoable": false,
|
||||||
|
"reason": "Filesystem moves are persistent; move the resource back explicitly to revert"}}
|
||||||
|
|
||||||
|
|
||||||
|
func _reconcile_move(targets: Dictionary, source: String, destination: String, refresh_editor: bool = true) -> void:
|
||||||
|
var efs := EditorInterface.get_resource_filesystem()
|
||||||
|
for old_path: String in targets:
|
||||||
|
var new_path := destination + old_path.trim_prefix(source)
|
||||||
|
var actual := new_path if FileAccess.file_exists(new_path) else old_path
|
||||||
|
var uid: int = targets[old_path].uid
|
||||||
|
if uid != ResourceUID.INVALID_ID:
|
||||||
|
if ResourceUID.has_id(uid):
|
||||||
|
ResourceUID.set_id(uid, actual)
|
||||||
|
else:
|
||||||
|
ResourceUID.add_id(uid, actual)
|
||||||
|
if actual != old_path and ResourceLoader.has_cached(old_path):
|
||||||
|
var cached := ResourceLoader.get_cached_ref(old_path)
|
||||||
|
if cached != null:
|
||||||
|
cached.take_over_path(actual)
|
||||||
|
# Updating a missing path removes orphan UID sidecars. On partial
|
||||||
|
# outcomes retain those bytes for explicit recovery instead.
|
||||||
|
if efs != null and refresh_editor:
|
||||||
|
if old_path.to_lower() != actual.to_lower():
|
||||||
|
efs.update_file(old_path)
|
||||||
|
efs.update_file(actual)
|
||||||
|
|
||||||
|
|
||||||
|
func _remove(source: String, directory: bool, group: Array[Dictionary], targets: Dictionary, owners: Array, permanent: bool) -> Dictionary:
|
||||||
|
var removed: Array[String] = []
|
||||||
|
var failure := OK
|
||||||
|
for item in group:
|
||||||
|
var code: int
|
||||||
|
if _io.is_valid():
|
||||||
|
code = int(_io.call("remove" if permanent else "trash", item.from, ""))
|
||||||
|
else:
|
||||||
|
code = DirAccess.remove_absolute(item.from) if permanent else OS.move_to_trash(ProjectSettings.globalize_path(item.from))
|
||||||
|
if code != OK:
|
||||||
|
failure = code
|
||||||
|
break
|
||||||
|
removed.append(item.from)
|
||||||
|
var efs := EditorInterface.get_resource_filesystem()
|
||||||
|
for target: String in targets:
|
||||||
|
if FileAccess.file_exists(target):
|
||||||
|
continue
|
||||||
|
var uid: int = targets[target].uid
|
||||||
|
if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid):
|
||||||
|
ResourceUID.remove_id(uid)
|
||||||
|
if efs != null and failure == OK:
|
||||||
|
efs.update_file(target)
|
||||||
|
if failure != OK:
|
||||||
|
var remaining: Array[String] = []
|
||||||
|
for item in group:
|
||||||
|
if FileAccess.file_exists(item.from) or DirAccess.dir_exists_absolute(item.from):
|
||||||
|
remaining.append(item.from)
|
||||||
|
return _failure("Removal failed: %s" % error_string(failure), "unchanged" if removed.is_empty() else "partial",
|
||||||
|
{"removed": removed, "remaining": remaining, "trashed": not permanent})
|
||||||
|
return {"data": {"path": source, "kind": "directory" if directory else "file", "removed": targets.keys(),
|
||||||
|
"removed_count": targets.size(), "trashed": not permanent, "uids_released": _uid_count(targets),
|
||||||
|
"project_settings_cleared": [], "referenced_by": owners, "outcome": "committed", "scan_required": directory, "warnings": [], "undoable": false,
|
||||||
|
"reason": "Permanently deleted" if permanent else "Moved to the OS trash; restore it there to revert"}}
|
||||||
|
|
||||||
|
|
||||||
|
static func _uid_count(targets: Dictionary) -> int:
|
||||||
|
var count := 0
|
||||||
|
for entry in targets.values():
|
||||||
|
if entry.uid != ResourceUID.INVALID_ID:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_sidecar(path: String) -> bool:
|
||||||
|
return path.ends_with(".uid") or path.ends_with(".import")
|
||||||
|
|
||||||
|
|
||||||
|
static func _unchanged(message: String, details: Dictionary = {}) -> Dictionary:
|
||||||
|
return _failure(message, "unchanged", details, Errors.INVALID_PARAMS)
|
||||||
|
|
||||||
|
|
||||||
|
static func _failure(message: String, outcome: String, details: Dictionary, code: String = Errors.INTERNAL_ERROR) -> Dictionary:
|
||||||
|
var result := Errors.make(code, message)
|
||||||
|
result.error["data"] = details.merged({"outcome": outcome, "retry_safe": outcome in ["unchanged", "rolled_back"]}, true)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
static func _reserved(path: String) -> bool:
|
||||||
|
var normalized := path.to_lower().trim_suffix("/")
|
||||||
|
for reserved: String in ["res://.godot", "res://.git", PLUGIN_TREE]:
|
||||||
|
if normalized == reserved or normalized.begins_with(reserved + "/") or reserved.begins_with(normalized + "/"):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c21v8ma38tvs7
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
@tool
|
||||||
|
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
||||||
|
|
||||||
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
const MeshWorkload := preload("res://addons/godot_ai/utils/mesh_workload.gd")
|
||||||
|
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
|
||||||
|
|
||||||
|
## Bounded 3D mesh-only baking and explicit-map path queries in 2D/3D.
|
||||||
|
|
||||||
|
const _CLASSES := {
|
||||||
|
"2d": {
|
||||||
|
"region": "NavigationRegion2D",
|
||||||
|
"mesh": "NavigationPolygon",
|
||||||
|
},
|
||||||
|
"3d": {
|
||||||
|
"region": "NavigationRegion3D",
|
||||||
|
"mesh": "NavigationMesh",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
## One deferred bake may spend this long across editor frames. The Python
|
||||||
|
## handler's timeout is this plus its transport margin (a source-shape test
|
||||||
|
## keeps the two together).
|
||||||
|
const _BAKE_DEFERRED_TIMEOUT_MS := 30000
|
||||||
|
const _SOURCE_MAX_NODES := 256
|
||||||
|
const _SOURCE_MAX_GRID_CELLS := 1000000
|
||||||
|
|
||||||
|
## In-flight bakes keyed by region instance id. `bake()` reserves its region
|
||||||
|
## before the first yield so a second call in the same frame cannot start a
|
||||||
|
## second bake on a region Godot is already baking (the engine logs
|
||||||
|
## "NavigationMesh is already baking" and the two jobs split the result).
|
||||||
|
## Every terminal path releases the reservation.
|
||||||
|
static var _active_bakes: Dictionary = {}
|
||||||
|
|
||||||
|
var _undo_redo: EditorUndoRedoManager
|
||||||
|
var _connection
|
||||||
|
|
||||||
|
|
||||||
|
func _init(undo_redo: EditorUndoRedoManager, connection = null) -> void:
|
||||||
|
_undo_redo = undo_redo
|
||||||
|
_connection = connection
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# navigation_bake
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Bake a 3D region's navigation mesh and commit a scene-anchored swap
|
||||||
|
## between the retained pre-bake and baked resources.
|
||||||
|
##
|
||||||
|
## Source collection visits one bounded mesh/container per frame, without the
|
||||||
|
## general scene parser or custom parser callbacks. Only the engine bake runs
|
||||||
|
## on a background thread. Undo/redo retain the exact before/after resources.
|
||||||
|
func bake(params: Dictionary) -> Dictionary:
|
||||||
|
var resolved := _resolve_region(params)
|
||||||
|
if resolved.has("error"):
|
||||||
|
return resolved
|
||||||
|
var region: Node = resolved.node
|
||||||
|
var dimension: String = resolved.dimension
|
||||||
|
var before: Resource = _get_region_mesh(region, dimension)
|
||||||
|
if before == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
|
"%s has no %s resource" % [resolved.path, _CLASSES[dimension].mesh])
|
||||||
|
var source_error := _validate_bake_source(dimension, before)
|
||||||
|
if not source_error.is_empty():
|
||||||
|
return source_error
|
||||||
|
if _active_bakes.has(region.get_instance_id()) or bool(region.call("is_baking")) or NavigationServer3D.is_baking_navigation_mesh(before):
|
||||||
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
||||||
|
"%s is already baking a navigation mesh" % resolved.path)
|
||||||
|
|
||||||
|
var request_id: String = params.get("_request_id", "")
|
||||||
|
if _connection == null or request_id.is_empty():
|
||||||
|
## The bake is threaded and answered out-of-band, so a direct caller
|
||||||
|
## (batch_execute, unit tests) cannot wait for it without blocking the
|
||||||
|
## editor's frame budget. Refuse instead of silently baking in place.
|
||||||
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
||||||
|
"navigation_bake is deferred (threaded bake, bounded deadline); call "
|
||||||
|
+ "navigation_manage(op='bake') directly - batch_execute cannot await it")
|
||||||
|
|
||||||
|
var force_sync := bool(params.get("force_sync", true))
|
||||||
|
var prepared := _begin_bake(region, dimension)
|
||||||
|
if prepared.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||||
|
"Failed to prepare the %s bake" % _CLASSES[dimension].mesh)
|
||||||
|
var job := _bake_job(
|
||||||
|
region, dimension, resolved.scene_root, prepared.before, prepared.working,
|
||||||
|
_undo_redo, _connection, request_id, force_sync
|
||||||
|
)
|
||||||
|
## Reserve the region before the driver yields: the region's own
|
||||||
|
## native bake has not started during source collection, so checking only
|
||||||
|
## the engine bake state would admit a second request for the same region.
|
||||||
|
_active_bakes[region.get_instance_id()] = request_id
|
||||||
|
## No step here: the dispatcher registers the deferred request only after
|
||||||
|
## this handler returns the sentinel, and `_bake_step`'s first pending
|
||||||
|
## check would otherwise see the request as expired and abandon the job.
|
||||||
|
## `_drive_bake_job` performs the first step after its registration yield.
|
||||||
|
_drive_bake_job(job)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"_deferred": true,
|
||||||
|
"_deferred_timeout_ms": _BAKE_DEFERRED_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# navigation_path_get
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Query a path on an explicitly selected navigation map. Read-only: it never
|
||||||
|
## guesses a region and never changes the shared async-iteration policy unless
|
||||||
|
## `force_sync` asks it to.
|
||||||
|
func path_get(params: Dictionary) -> Dictionary:
|
||||||
|
var dimension_result := _resolve_dimension(params)
|
||||||
|
if dimension_result.has("error"):
|
||||||
|
return dimension_result
|
||||||
|
var dimension: String = dimension_result.dimension
|
||||||
|
|
||||||
|
var from := _coerce_vector(params.get("from_point", null), dimension, "from_point")
|
||||||
|
if from.has("error"):
|
||||||
|
return from
|
||||||
|
var to := _coerce_vector(params.get("to_point", null), dimension, "to_point")
|
||||||
|
if to.has("error"):
|
||||||
|
return to
|
||||||
|
var optimize := bool(params.get("optimize", true))
|
||||||
|
var navigation_layers := int(params.get("navigation_layers", 1))
|
||||||
|
var force_sync := bool(params.get("force_sync", false))
|
||||||
|
var region_path := String(params.get("region_path", ""))
|
||||||
|
|
||||||
|
var scene_root := EditorInterface.get_edited_scene_root()
|
||||||
|
if scene_root == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No edited scene open")
|
||||||
|
|
||||||
|
var map_result := _resolve_query_map(
|
||||||
|
scene_root, dimension, region_path, params.get("scene_file", "")
|
||||||
|
)
|
||||||
|
if map_result.has("error"):
|
||||||
|
return map_result
|
||||||
|
|
||||||
|
var points: Array = []
|
||||||
|
if dimension == "3d":
|
||||||
|
if force_sync:
|
||||||
|
_force_map_sync_3d(map_result.map)
|
||||||
|
for point in NavigationServer3D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers):
|
||||||
|
points.append(VariantSerializer.serialize(point))
|
||||||
|
else:
|
||||||
|
if force_sync:
|
||||||
|
_force_map_sync_2d(map_result.map)
|
||||||
|
for point in NavigationServer2D.map_get_path(map_result.map, from.ok, to.ok, optimize, navigation_layers):
|
||||||
|
points.append(VariantSerializer.serialize(point))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"dimension": dimension,
|
||||||
|
"from_point": VariantSerializer.serialize(from.ok),
|
||||||
|
"to_point": VariantSerializer.serialize(to.ok),
|
||||||
|
"optimize": optimize,
|
||||||
|
"navigation_layers": navigation_layers,
|
||||||
|
"force_sync": force_sync,
|
||||||
|
"region_path": map_result.region_path,
|
||||||
|
"map_source": map_result.map_source,
|
||||||
|
"point_count": points.size(),
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helpers — deferred bake
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Snapshot the pre-bake resource and install the working duplicate the
|
||||||
|
## threaded bake writes into. Returns `{}` when there is no mesh to bake.
|
||||||
|
## Baking in place would mutate the resource the undo action restores, so a
|
||||||
|
## second undo after a redo could no longer reach the pre-bake state.
|
||||||
|
static func _begin_bake(region: Node, dimension: String) -> Dictionary:
|
||||||
|
var before: Resource = _get_region_mesh(region, dimension)
|
||||||
|
if before == null:
|
||||||
|
return {}
|
||||||
|
var working: Resource = before.duplicate()
|
||||||
|
if dimension == "3d":
|
||||||
|
region.call("set_navigation_mesh", working)
|
||||||
|
else:
|
||||||
|
region.call("set_navigation_polygon", working)
|
||||||
|
return {"before": before, "working": working}
|
||||||
|
|
||||||
|
|
||||||
|
## Request state retained by the static frame driver through collection/baking.
|
||||||
|
static func _bake_job(
|
||||||
|
region: Node, dimension: String, scene_root: Node, before: Resource,
|
||||||
|
working: Resource, undo_redo: EditorUndoRedoManager, connection, request_id: String,
|
||||||
|
force_sync: bool,
|
||||||
|
) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"region": region,
|
||||||
|
"region_id": region.get_instance_id(),
|
||||||
|
"dimension": dimension,
|
||||||
|
"scene_root": scene_root,
|
||||||
|
"before": before,
|
||||||
|
"working": working,
|
||||||
|
"undo_redo": undo_redo,
|
||||||
|
"connection": connection,
|
||||||
|
"request_id": request_id,
|
||||||
|
"force_sync": force_sync,
|
||||||
|
"phase": "start",
|
||||||
|
"started_ms": Time.get_ticks_msec(),
|
||||||
|
"deadline_ms": _BAKE_DEFERRED_TIMEOUT_MS,
|
||||||
|
"parse_ms": 0,
|
||||||
|
"source_data": NavigationMeshSourceGeometryData3D.new(),
|
||||||
|
"pending": [region],
|
||||||
|
"sources": [],
|
||||||
|
"meshes": [],
|
||||||
|
"source_changed": false,
|
||||||
|
"triangles": 0,
|
||||||
|
"vertices": 0,
|
||||||
|
"bounds": AABB(),
|
||||||
|
"has_bounds": false,
|
||||||
|
"result": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Drop this job's reservation so the region can bake again. Every terminal
|
||||||
|
## path (commit, abort, abandonment, or a driver that never starts) calls it.
|
||||||
|
static func _release_bake(job: Dictionary) -> void:
|
||||||
|
for mesh in job.meshes:
|
||||||
|
var callback := _source_changed.bind(job)
|
||||||
|
if mesh.changed.is_connected(callback):
|
||||||
|
mesh.changed.disconnect(callback)
|
||||||
|
job.meshes.clear()
|
||||||
|
_active_bakes.erase(int(job.region_id))
|
||||||
|
|
||||||
|
|
||||||
|
## Advance a bake job by one editor-frame check. Returns true once the job is
|
||||||
|
## resolved (committed, aborted, or abandoned); `job.result` holds the reply,
|
||||||
|
## or stays empty when the request was abandoned and nothing may be answered.
|
||||||
|
static func _bake_step(job: Dictionary) -> bool:
|
||||||
|
if str(job.phase) == "done":
|
||||||
|
return true
|
||||||
|
## Read the region untyped first: a typed `Node` assignment on a freed
|
||||||
|
## instance raises "Trying to assign invalid previously freed instance"
|
||||||
|
## before any validity check could run.
|
||||||
|
var region_ref = job.region
|
||||||
|
if not is_instance_valid(region_ref) or not (region_ref as Node).is_inside_tree():
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
|
||||||
|
"The navigation region went away while it was baking"))
|
||||||
|
return true
|
||||||
|
var region: Node = region_ref
|
||||||
|
var connection = job.connection
|
||||||
|
if connection != null and not _deferred_request_pending(connection, str(job.request_id)):
|
||||||
|
## The dispatcher gave up on this request (timeout, client gone):
|
||||||
|
## restore the pre-bake resource and answer nothing.
|
||||||
|
_bake_restore(job)
|
||||||
|
_release_bake(job)
|
||||||
|
job.phase = "done"
|
||||||
|
return true
|
||||||
|
if Time.get_ticks_msec() - int(job.started_ms) > int(job.deadline_ms):
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT,
|
||||||
|
"navigation_bake exceeded its %d ms budget" % int(job.deadline_ms)))
|
||||||
|
return true
|
||||||
|
if EditorInterface.get_edited_scene_root() != job.scene_root:
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH,
|
||||||
|
"The edited scene changed while the navigation mesh was baking"))
|
||||||
|
return true
|
||||||
|
if _get_region_mesh(region, str(job.dimension)) != job.working:
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
|
"The region's navigation resource was replaced while the bake was in flight; the bake result was discarded"))
|
||||||
|
return true
|
||||||
|
if bool(job.source_changed):
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH,
|
||||||
|
"A source mesh changed during navigation baking; retry with stable source geometry"))
|
||||||
|
return true
|
||||||
|
if str(job.phase) == "start" or str(job.phase) == "collect":
|
||||||
|
var source_error := _validate_bake_source(str(job.dimension), job.working)
|
||||||
|
if not source_error.is_empty():
|
||||||
|
_bake_abort(job, source_error)
|
||||||
|
return true
|
||||||
|
job.phase = "collect"
|
||||||
|
var started := Time.get_ticks_usec()
|
||||||
|
var collected := _collect_source_step(job)
|
||||||
|
job.parse_ms = float(job.parse_ms) + (Time.get_ticks_usec() - started) / 1000.0
|
||||||
|
if not collected.is_empty():
|
||||||
|
_bake_abort(job, collected)
|
||||||
|
return true
|
||||||
|
if not job.pending.is_empty():
|
||||||
|
return false
|
||||||
|
if not job.source_data.has_data():
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "No supported mesh triangles found below the navigation region"))
|
||||||
|
return true
|
||||||
|
if not _sources_match(job):
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while geometry was collected"))
|
||||||
|
return true
|
||||||
|
var final_grid_error := _grid_error(job.bounds, job.working)
|
||||||
|
if not final_grid_error.is_empty():
|
||||||
|
_bake_abort(job, final_grid_error)
|
||||||
|
return true
|
||||||
|
NavigationServer3D.bake_from_source_geometry_data_async(job.working, job.source_data)
|
||||||
|
job.phase = "baking"
|
||||||
|
return false
|
||||||
|
if NavigationServer3D.is_baking_navigation_mesh(job.working):
|
||||||
|
return false
|
||||||
|
if not _sources_match(job):
|
||||||
|
_bake_abort(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Navigation source nodes changed while baking"))
|
||||||
|
return true
|
||||||
|
_bake_commit(job)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
static func _validate_bake_source(dimension: String, mesh: Resource) -> Dictionary:
|
||||||
|
if dimension != "3d":
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "navigation_bake supports bounded 3D mesh-only sources; 2D baking is not supported (2D path queries remain available)")
|
||||||
|
if mesh.geometry_source_geometry_mode != NavigationMesh.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN or mesh.geometry_parsed_geometry_type == NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "navigation_bake requires root-children mesh-instance or both source settings; collider-only and group sources are not supported")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
static func _source_changed(job: Dictionary) -> void:
|
||||||
|
job.source_changed = true
|
||||||
|
|
||||||
|
|
||||||
|
static func _sources_match(job: Dictionary) -> bool:
|
||||||
|
if bool(job.source_changed):
|
||||||
|
return false
|
||||||
|
for source in job.sources:
|
||||||
|
var node = source.node
|
||||||
|
if not is_instance_valid(node) or not node.is_inside_tree() or node.get_parent() != source.parent or node.get_child_count() != int(source.children):
|
||||||
|
return false
|
||||||
|
if node is Node3D and node.global_transform != source.transform:
|
||||||
|
return false
|
||||||
|
if node is MeshInstance3D and node.mesh != source.mesh:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
static func _grid_error(bounds: AABB, mesh: NavigationMesh) -> Dictionary:
|
||||||
|
if not is_finite(mesh.cell_size) or not is_finite(mesh.cell_height) or mesh.cell_size <= 0.0 or mesh.cell_height <= 0.0:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation cell size and height must be finite and positive")
|
||||||
|
var size := bounds.size
|
||||||
|
var filter_size := mesh.filter_baking_aabb.size
|
||||||
|
size = Vector3(maxf(size.x, filter_size.x), maxf(size.y, filter_size.y), maxf(size.z, filter_size.z))
|
||||||
|
var cells := Vector3(size.x / mesh.cell_size, size.y / mesh.cell_height, size.z / mesh.cell_size)
|
||||||
|
var count := 1
|
||||||
|
for extent in [cells.x, cells.y, cells.z]:
|
||||||
|
if not is_finite(extent) or extent > _SOURCE_MAX_GRID_CELLS:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source extent/cell size exceeds the bounded voxel grid; reduce the extent or increase cell size")
|
||||||
|
count *= maxi(1, int(ceil(extent)))
|
||||||
|
if count > _SOURCE_MAX_GRID_CELLS:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source exceeds the %d-cell voxel grid limit; reduce the extent or increase cell size" % _SOURCE_MAX_GRID_CELLS)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
static func _collect_source_step(job: Dictionary) -> Dictionary:
|
||||||
|
var node = job.pending.pop_back()
|
||||||
|
if not is_instance_valid(node) or not node.is_inside_tree():
|
||||||
|
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "A navigation source node was removed during collection")
|
||||||
|
if node != job.region and (node.get_script() != null or node.get_class() not in ["Node", "Node3D", "MeshInstance3D"]):
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Unsupported navigation source %s (%s): mesh-only baking accepts unscripted Node/Node3D containers and MeshInstance3D; colliders, CSG, GridMap, obstacles and custom parser sources are not supported" % [node.name, node.get_class()])
|
||||||
|
var children: int = node.get_child_count()
|
||||||
|
if job.sources.size() + job.pending.size() + children + 1 > _SOURCE_MAX_NODES:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source traversal exceeds %d nodes" % _SOURCE_MAX_NODES)
|
||||||
|
var source := {"node": node, "parent": node.get_parent(), "children": children}
|
||||||
|
if node is Node3D:
|
||||||
|
source.transform = node.global_transform
|
||||||
|
for index in children:
|
||||||
|
job.pending.append(node.get_child(index))
|
||||||
|
if node is MeshInstance3D:
|
||||||
|
var mesh: Mesh = node.mesh
|
||||||
|
source.mesh = mesh
|
||||||
|
if mesh != null:
|
||||||
|
var counts := MeshWorkload.estimate(mesh)
|
||||||
|
if counts.has("error"):
|
||||||
|
return counts
|
||||||
|
if int(counts.triangles) == 0:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation source %s has no supported triangles" % node.name)
|
||||||
|
job.triangles = int(job.triangles) + int(counts.triangles)
|
||||||
|
job.vertices = int(job.vertices) + int(counts.vertices)
|
||||||
|
if int(job.triangles) > MeshWorkload.MAX_TRIANGLES or int(job.vertices) > MeshWorkload.MAX_VERTICES:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation sources exceed the aggregate %d-triangle/%d-vertex limit" % [MeshWorkload.MAX_TRIANGLES, MeshWorkload.MAX_VERTICES])
|
||||||
|
var root_transform: Transform3D = job.region.global_transform
|
||||||
|
if not root_transform.is_finite() or is_zero_approx(root_transform.basis.determinant()):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation region transform must be finite and invertible")
|
||||||
|
var relative: Transform3D = root_transform.affine_inverse() * node.global_transform
|
||||||
|
var bounds: AABB = relative * mesh.get_aabb()
|
||||||
|
if not relative.is_finite() or not bounds.is_finite():
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Navigation mesh transform/bounds must be finite")
|
||||||
|
job.bounds = job.bounds.merge(bounds) if bool(job.has_bounds) else bounds
|
||||||
|
job.has_bounds = true
|
||||||
|
var grid_error := _grid_error(job.bounds, job.working)
|
||||||
|
if not grid_error.is_empty():
|
||||||
|
return grid_error
|
||||||
|
if not job.meshes.has(mesh):
|
||||||
|
mesh.changed.connect(_source_changed.bind(job))
|
||||||
|
job.meshes.append(mesh)
|
||||||
|
job.source_data.add_mesh(mesh, relative)
|
||||||
|
job.sources.append(source)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
## Push the baked resource to the server region and commit one scene-anchored
|
||||||
|
## swap action. Every do/undo target is the region node, so the action lands in
|
||||||
|
## the edited scene's history — a RefCounted handler target would select the
|
||||||
|
## global history and trip the editor's history-mismatch check.
|
||||||
|
static func _bake_commit(job: Dictionary) -> void:
|
||||||
|
var region: Node = job.region
|
||||||
|
var dimension: String = job.dimension
|
||||||
|
var working: Resource = job.working
|
||||||
|
var rid: RID = region.call("get_rid")
|
||||||
|
if rid.is_valid():
|
||||||
|
if dimension == "3d":
|
||||||
|
NavigationServer3D.region_set_navigation_mesh(rid, working)
|
||||||
|
else:
|
||||||
|
NavigationServer2D.region_set_navigation_polygon(rid, working)
|
||||||
|
if bool(job.force_sync):
|
||||||
|
var map: RID = region.call("get_navigation_map")
|
||||||
|
if dimension == "3d":
|
||||||
|
_force_map_sync_3d(map)
|
||||||
|
else:
|
||||||
|
_force_map_sync_2d(map)
|
||||||
|
|
||||||
|
var undo_redo: EditorUndoRedoManager = job.undo_redo
|
||||||
|
undo_redo.create_action("MCP: Bake navigation on %s" % region.name)
|
||||||
|
if dimension == "3d":
|
||||||
|
undo_redo.add_do_method(region, "set_navigation_mesh", working)
|
||||||
|
undo_redo.add_undo_method(region, "set_navigation_mesh", job.before)
|
||||||
|
else:
|
||||||
|
undo_redo.add_do_method(region, "set_navigation_polygon", working)
|
||||||
|
undo_redo.add_undo_method(region, "set_navigation_polygon", job.before)
|
||||||
|
## Both retained resources stay referenced by the action: the scene holds
|
||||||
|
## only one of them at a time, the other must survive for undo/redo.
|
||||||
|
undo_redo.add_do_reference(working)
|
||||||
|
undo_redo.add_undo_reference(job.before)
|
||||||
|
## The region already holds `working`, so record without re-running do.
|
||||||
|
undo_redo.commit_action(false)
|
||||||
|
|
||||||
|
var polygon_count := 0
|
||||||
|
var vertex_count := 0
|
||||||
|
if working != null:
|
||||||
|
polygon_count = int(working.call("get_polygon_count"))
|
||||||
|
vertex_count = int(working.call("get_vertices").size())
|
||||||
|
job.result = {
|
||||||
|
"data": {
|
||||||
|
"path": McpScenePath.from_node(region, job.scene_root),
|
||||||
|
"mesh_class": working.get_class() if working != null else "",
|
||||||
|
"polygon_count": polygon_count,
|
||||||
|
"vertex_count": vertex_count,
|
||||||
|
"force_sync": bool(job.force_sync),
|
||||||
|
"bake_settle": "settled",
|
||||||
|
"parse_ms": float(job.parse_ms),
|
||||||
|
"source_mode": "mesh_only",
|
||||||
|
"source_triangles": int(job.triangles),
|
||||||
|
"undoable": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_release_bake(job)
|
||||||
|
job.phase = "done"
|
||||||
|
|
||||||
|
|
||||||
|
static func _bake_abort(job: Dictionary, error: Dictionary) -> void:
|
||||||
|
_bake_restore(job)
|
||||||
|
_release_bake(job)
|
||||||
|
job.result = error
|
||||||
|
job.phase = "done"
|
||||||
|
|
||||||
|
|
||||||
|
## Put the pre-bake resource back after an aborted bake. The region may have
|
||||||
|
## been freed meanwhile, so it is validity-checked before the typed assignment;
|
||||||
|
## and the pre-bake resource is only restored while the region still holds this
|
||||||
|
## job's working duplicate — a newer assignment must not be clobbered.
|
||||||
|
static func _bake_restore(job: Dictionary) -> void:
|
||||||
|
var region_ref = job.region
|
||||||
|
if not is_instance_valid(region_ref):
|
||||||
|
return
|
||||||
|
var region: Node = region_ref
|
||||||
|
if _get_region_mesh(region, str(job.dimension)) != job.working:
|
||||||
|
return
|
||||||
|
if str(job.dimension) == "3d":
|
||||||
|
region.call("set_navigation_mesh", job.before)
|
||||||
|
else:
|
||||||
|
region.call("set_navigation_polygon", job.before)
|
||||||
|
|
||||||
|
|
||||||
|
## Drive a bake job one editor frame at a time and reply when it ends.
|
||||||
|
## `static` is load-bearing: the coroutine must outlive this RefCounted
|
||||||
|
## handler, which can be freed mid-await by an editor_reload_plugin.
|
||||||
|
static func _drive_bake_job(job: Dictionary) -> void:
|
||||||
|
var connection = job.connection
|
||||||
|
if not is_instance_valid(connection):
|
||||||
|
## `_begin_bake` already replaced the region's resource with the working
|
||||||
|
## duplicate and no undo action owns it yet, so a driver that cannot run
|
||||||
|
## must put the pre-bake resource back (ownership-guarded) before
|
||||||
|
## releasing the region for another bake.
|
||||||
|
_bake_restore(job)
|
||||||
|
_release_bake(job)
|
||||||
|
return
|
||||||
|
var tree: SceneTree = connection.get_tree()
|
||||||
|
if tree == null:
|
||||||
|
_bake_restore(job)
|
||||||
|
_release_bake(job)
|
||||||
|
return
|
||||||
|
var work := ScriptWork.begin("navigation_bake")
|
||||||
|
## The first yield lets the dispatcher register the deferred request before
|
||||||
|
## any result or abort can be answered.
|
||||||
|
await tree.process_frame
|
||||||
|
while not _bake_step(job):
|
||||||
|
await tree.process_frame
|
||||||
|
ScriptWork.finish(work)
|
||||||
|
if is_instance_valid(connection) and not job.result.is_empty():
|
||||||
|
connection.send_deferred_response(str(job.request_id), job.result)
|
||||||
|
|
||||||
|
|
||||||
|
## Check that the connection and deferred dispatcher entry are both live.
|
||||||
|
static func _deferred_request_pending(connection, request_id: String) -> bool:
|
||||||
|
if not is_instance_valid(connection):
|
||||||
|
return false
|
||||||
|
var dispatcher = connection.dispatcher
|
||||||
|
return dispatcher == null or dispatcher.has_pending_deferred_response(request_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Sync a 3D map immediately. Godot 4.7's navigation server iterates maps
|
||||||
|
## asynchronously by default, and `map_force_update` is documented as
|
||||||
|
## unsupported in that mode — turn async iterations off for the forced sync,
|
||||||
|
## then restore the previous setting. Only ever called when the caller asked
|
||||||
|
## for it (`force_sync`), never silently from a read.
|
||||||
|
static func _force_map_sync_3d(map: RID) -> void:
|
||||||
|
if not map.is_valid():
|
||||||
|
return
|
||||||
|
var was_async := NavigationServer3D.map_get_use_async_iterations(map)
|
||||||
|
if was_async:
|
||||||
|
NavigationServer3D.map_set_use_async_iterations(map, false)
|
||||||
|
NavigationServer3D.map_force_update(map)
|
||||||
|
if was_async:
|
||||||
|
NavigationServer3D.map_set_use_async_iterations(map, true)
|
||||||
|
|
||||||
|
|
||||||
|
static func _force_map_sync_2d(map: RID) -> void:
|
||||||
|
if not map.is_valid():
|
||||||
|
return
|
||||||
|
var was_async := NavigationServer2D.map_get_use_async_iterations(map)
|
||||||
|
if was_async:
|
||||||
|
NavigationServer2D.map_set_use_async_iterations(map, false)
|
||||||
|
NavigationServer2D.map_force_update(map)
|
||||||
|
if was_async:
|
||||||
|
NavigationServer2D.map_set_use_async_iterations(map, true)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helpers — resolution
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
static func _resolve_dimension(params: Dictionary) -> Dictionary:
|
||||||
|
var dimension: String = params.get("dimension", "3d")
|
||||||
|
if dimension != "2d" and dimension != "3d":
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Invalid dimension '%s'. Valid: 2d, 3d" % dimension)
|
||||||
|
return {"dimension": dimension}
|
||||||
|
|
||||||
|
|
||||||
|
## Resolve `path` to a navigation region and infer its dimension from the
|
||||||
|
## class. Success shape: `{node, dimension, path, scene_root}`.
|
||||||
|
func _resolve_region(params: Dictionary) -> Dictionary:
|
||||||
|
var resolved := McpNodeValidator.resolve_or_error(
|
||||||
|
params.get("path", ""), "path", params.get("scene_file", "")
|
||||||
|
)
|
||||||
|
if resolved.has("error"):
|
||||||
|
return resolved
|
||||||
|
var node: Node = resolved.node
|
||||||
|
var dimension := ""
|
||||||
|
if node is NavigationRegion2D:
|
||||||
|
dimension = "2d"
|
||||||
|
elif node is NavigationRegion3D:
|
||||||
|
dimension = "3d"
|
||||||
|
else:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"Node at %s is %s — expected a navigation region" % [resolved.path, node.get_class()])
|
||||||
|
return {
|
||||||
|
"node": node,
|
||||||
|
"dimension": dimension,
|
||||||
|
"path": McpScenePath.from_node(node, resolved.scene_root),
|
||||||
|
"scene_root": resolved.scene_root,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## The map a path query runs against: the explicitly named region's map, or
|
||||||
|
## the edited scene root's world map when no region is given. Never guesses
|
||||||
|
## the scene's "first" region — a scene can host several maps.
|
||||||
|
static func _resolve_query_map(
|
||||||
|
scene_root: Node, dimension: String, region_path: String, scene_file: String
|
||||||
|
) -> Dictionary:
|
||||||
|
if not region_path.is_empty():
|
||||||
|
var resolved := McpNodeValidator.resolve_or_error(region_path, "region_path", scene_file)
|
||||||
|
if resolved.has("error"):
|
||||||
|
return resolved
|
||||||
|
var node: Node = resolved.node
|
||||||
|
var expected: String = _CLASSES[dimension].region
|
||||||
|
if not _is_a_class(node, expected):
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"Node at %s is %s — expected a %s for a %s query"
|
||||||
|
% [resolved.path, node.get_class(), expected, dimension.to_upper()])
|
||||||
|
var region_map: RID = node.call("get_navigation_map")
|
||||||
|
if not region_map.is_valid():
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
|
"%s has no valid navigation map" % resolved.path)
|
||||||
|
return {"map": region_map, "map_source": "region", "region_path": resolved.path}
|
||||||
|
var world_method := "get_world_3d" if dimension == "3d" else "get_world_2d"
|
||||||
|
if scene_root.has_method(world_method):
|
||||||
|
var world_map: RID = scene_root.call(world_method).navigation_map
|
||||||
|
if world_map.is_valid():
|
||||||
|
return {"map": world_map, "map_source": "world", "region_path": ""}
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
|
"No %s navigation map found in the edited scene" % dimension.to_upper())
|
||||||
|
|
||||||
|
|
||||||
|
## Exact class or subclass check. `ClassDB.is_parent_class` is strict, so an
|
||||||
|
## exact-class node would otherwise be rejected.
|
||||||
|
static func _is_a_class(node: Node, expected: String) -> bool:
|
||||||
|
return node.get_class() == expected or ClassDB.is_parent_class(node.get_class(), expected)
|
||||||
|
|
||||||
|
|
||||||
|
static func _get_region_mesh(region: Node, dimension: String) -> Resource:
|
||||||
|
if dimension == "3d":
|
||||||
|
return region.get("navigation_mesh")
|
||||||
|
return region.get("navigation_polygon")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helpers — path points
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Parse a world point for path queries from {x,y[,z]} / [x,y[,z]].
|
||||||
|
static func _coerce_vector(raw: Variant, dimension: String, param_name: String) -> Dictionary:
|
||||||
|
var parsed: Variant = null
|
||||||
|
if dimension == "3d":
|
||||||
|
parsed = McpJsonValues.parse_vector3(raw)
|
||||||
|
else:
|
||||||
|
parsed = McpJsonValues.parse_vector2(raw)
|
||||||
|
if parsed == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"'%s' must be a %s point ({x,y%s} or [x,y%s])" % [
|
||||||
|
param_name, dimension.to_upper(), ",z" if dimension == "3d" else "", ",z" if dimension == "3d" else ""
|
||||||
|
])
|
||||||
|
return {"ok": parsed}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dsm4acmlregit
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@tool
|
@tool
|
||||||
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
||||||
|
|
||||||
|
const Refresh := preload("res://addons/godot_ai/handlers/physics_shape_refresh.gd")
|
||||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
|
||||||
## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
|
## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
|
||||||
@@ -26,6 +27,19 @@ const _SHAPE_3D_CLASSES := {
|
|||||||
"cylinder": "CylinderShape3D",
|
"cylinder": "CylinderShape3D",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
## `physics_shape_generate` additionally derives hull and concave shapes from
|
||||||
|
## the mesh itself. Kept separate from `_SHAPE_3D_CLASSES` so
|
||||||
|
## `physics_shape_autofit`, which sizes a primitive from bounds, cannot
|
||||||
|
## silently accept a type it cannot size.
|
||||||
|
const _GENERATE_SHAPE_3D_CLASSES := {
|
||||||
|
"box": "BoxShape3D",
|
||||||
|
"sphere": "SphereShape3D",
|
||||||
|
"capsule": "CapsuleShape3D",
|
||||||
|
"cylinder": "CylinderShape3D",
|
||||||
|
"convex": "ConvexPolygonShape3D",
|
||||||
|
"trimesh": "ConcavePolygonShape3D",
|
||||||
|
}
|
||||||
|
|
||||||
const _SHAPE_2D_CLASSES := {
|
const _SHAPE_2D_CLASSES := {
|
||||||
"rectangle": "RectangleShape2D",
|
"rectangle": "RectangleShape2D",
|
||||||
"circle": "CircleShape2D",
|
"circle": "CircleShape2D",
|
||||||
@@ -35,7 +49,16 @@ const _SHAPE_2D_CLASSES := {
|
|||||||
const _GENERATED_BODY_CLASSES := {
|
const _GENERATED_BODY_CLASSES := {
|
||||||
"static": "StaticBody3D",
|
"static": "StaticBody3D",
|
||||||
"area": "Area3D",
|
"area": "Area3D",
|
||||||
|
"rigid": "RigidBody3D",
|
||||||
|
"character": "CharacterBody3D",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
## A ConcavePolygonShape3D only simulates on a static body or an area; a
|
||||||
|
## moving body needs a convex or primitive shape.
|
||||||
|
const _GENERATE_CONCAVE_BODIES := ["static", "area"]
|
||||||
|
## Dynamic bodies move, so they must own the visual: as a sibling collider the
|
||||||
|
## body falls or slides away from the stationary mesh (issue #1053 review).
|
||||||
|
const _GENERATE_DYNAMIC_BODIES := ["rigid", "character"]
|
||||||
const _GENERATE_DIRECT_MAX_PATHS := 16
|
const _GENERATE_DIRECT_MAX_PATHS := 16
|
||||||
const _GENERATE_MAX_PATHS := 1024
|
const _GENERATE_MAX_PATHS := 1024
|
||||||
## One deferred request may spend this long across editor frames. The Python
|
## One deferred request may spend this long across editor frames. The Python
|
||||||
@@ -48,6 +71,16 @@ const _GENERATE_DEFERRED_TIMEOUT_MS := 30000
|
|||||||
const _GENERATE_FRAME_BUDGET_USEC := 4000
|
const _GENERATE_FRAME_BUDGET_USEC := 4000
|
||||||
const _GENERATE_COLLIDER_SUFFIX := "Collider"
|
const _GENERATE_COLLIDER_SUFFIX := "Collider"
|
||||||
const _GENERATE_SCALE_EPSILON := 0.0001
|
const _GENERATE_SCALE_EPSILON := 0.0001
|
||||||
|
## AABB centering leaves float noise in a generated collision offset (a
|
||||||
|
## centered CapsuleMesh reports a ~1.2e-07 Y center). Components below this
|
||||||
|
## snap to zero so generated transforms stay clean.
|
||||||
|
const _GENERATE_SNAP_EPSILON := 0.000001
|
||||||
|
## Engine hull calls cannot be preempted. These input limits bound admitted
|
||||||
|
## geometry, not elapsed time on every machine.
|
||||||
|
const MeshWorkload := preload("res://addons/godot_ai/utils/mesh_workload.gd")
|
||||||
|
const _GENERATE_HULL_MAX_TRIANGLES := MeshWorkload.MAX_TRIANGLES
|
||||||
|
const _GENERATE_HULL_MAX_VERTICES := MeshWorkload.MAX_VERTICES
|
||||||
|
const _GENERATE_HULL_MAX_SURFACES := MeshWorkload.MAX_SURFACES
|
||||||
|
|
||||||
|
|
||||||
## Accept either the short form ("box") or the matching Godot class name
|
## Accept either the short form ("box") or the matching Godot class name
|
||||||
@@ -104,11 +137,11 @@ func generate(params: Dictionary) -> Dictionary:
|
|||||||
## edited scene. Nothing here touches a node.
|
## edited scene. Nothing here touches a node.
|
||||||
static func _validate_generate_request(params: Dictionary) -> Dictionary:
|
static func _validate_generate_request(params: Dictionary) -> Dictionary:
|
||||||
var requested_shape := String(params.get("shape_type", "box"))
|
var requested_shape := String(params.get("shape_type", "box"))
|
||||||
var shape_type := _normalize_shape_type(_SHAPE_3D_CLASSES, requested_shape)
|
var shape_type := "auto" if requested_shape == "auto" else _normalize_shape_type(_GENERATE_SHAPE_3D_CLASSES, requested_shape)
|
||||||
if shape_type.is_empty():
|
if shape_type.is_empty():
|
||||||
return ErrorCodes.make(
|
return ErrorCodes.make(
|
||||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
"Invalid shape_type '%s'. Valid: %s" % [requested_shape, ", ".join(_SHAPE_3D_CLASSES.keys())]
|
"Invalid shape_type '%s'. Valid: auto, %s" % [requested_shape, ", ".join(_GENERATE_SHAPE_3D_CLASSES.keys())]
|
||||||
)
|
)
|
||||||
var body_type := String(params.get("body_type", "static"))
|
var body_type := String(params.get("body_type", "static"))
|
||||||
if not _GENERATED_BODY_CLASSES.has(body_type):
|
if not _GENERATED_BODY_CLASSES.has(body_type):
|
||||||
@@ -116,7 +149,36 @@ static func _validate_generate_request(params: Dictionary) -> Dictionary:
|
|||||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
"Invalid body_type '%s'. Valid: %s" % [body_type, ", ".join(_GENERATED_BODY_CLASSES.keys())]
|
"Invalid body_type '%s'. Valid: %s" % [body_type, ", ".join(_GENERATED_BODY_CLASSES.keys())]
|
||||||
)
|
)
|
||||||
|
if shape_type == "trimesh" and not _GENERATE_CONCAVE_BODIES.has(body_type):
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
(
|
||||||
|
"body_type '%s' cannot carry a trimesh shape — a ConcavePolygonShape3D only "
|
||||||
|
+ "simulates on a StaticBody3D or Area3D; use shape_type 'convex' or a primitive"
|
||||||
|
) % body_type
|
||||||
|
)
|
||||||
|
## A dynamic body must own its visual mesh. Default the wrap on for
|
||||||
|
## rigid/character and refuse an explicit opt-out: a detached dynamic body
|
||||||
|
## moves away from the stationary mesh. static/area keep the published
|
||||||
|
## sibling default (`false`).
|
||||||
|
var dynamic_body := _GENERATE_DYNAMIC_BODIES.has(body_type)
|
||||||
|
var reparent_mesh := dynamic_body
|
||||||
|
if params.has("reparent_mesh"):
|
||||||
|
if not params["reparent_mesh"] is bool:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "reparent_mesh must be a boolean")
|
||||||
|
reparent_mesh = params["reparent_mesh"]
|
||||||
|
if dynamic_body and not reparent_mesh:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
(
|
||||||
|
"body_type '%s' must own its visual mesh — a detached dynamic body "
|
||||||
|
+ "moves away from the stationary mesh. Pass reparent_mesh=true, or use "
|
||||||
|
+ "body_type 'static' or 'area' for a sibling collider"
|
||||||
|
) % body_type
|
||||||
|
)
|
||||||
|
|
||||||
|
if params.has("overwrite") and params.overwrite is not bool:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "overwrite must be a boolean")
|
||||||
var raw_paths: Variant = params.get("paths", [])
|
var raw_paths: Variant = params.get("paths", [])
|
||||||
if not raw_paths is Array:
|
if not raw_paths is Array:
|
||||||
return ErrorCodes.make(
|
return ErrorCodes.make(
|
||||||
@@ -158,13 +220,44 @@ static func _validate_generate_request(params: Dictionary) -> Dictionary:
|
|||||||
"scene_file": params.get("scene_file", ""),
|
"scene_file": params.get("scene_file", ""),
|
||||||
"shape_type": shape_type,
|
"shape_type": shape_type,
|
||||||
"body_type": body_type,
|
"body_type": body_type,
|
||||||
|
"reparent_mesh": reparent_mesh,
|
||||||
"paths": paths,
|
"paths": paths,
|
||||||
|
"overwrite": params.get("overwrite", false),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func _mesh_workload(mesh: Mesh) -> Dictionary:
|
||||||
|
return MeshWorkload.estimate(mesh)
|
||||||
|
|
||||||
|
|
||||||
|
static func _validate_hull_workload(mesh: Mesh, mesh_path: String, shape_type: String) -> Dictionary:
|
||||||
|
var workload := _mesh_workload(mesh)
|
||||||
|
if workload.has("error"):
|
||||||
|
return workload
|
||||||
|
if int(workload.triangles) == 0:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"MeshInstance3D at %s has a mesh with no faces - a %s shape needs triangles" % [mesh_path, shape_type])
|
||||||
|
if int(workload.triangles) > _GENERATE_HULL_MAX_TRIANGLES or int(workload.vertices) > _GENERATE_HULL_MAX_VERTICES:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"MeshInstance3D at %s exceeds the synchronous hull input limit: at most %d triangles and %d vertices; estimated %d triangles and %d vertices. Reduce geometry or use a primitive collision shape." % [
|
||||||
|
mesh_path, _GENERATE_HULL_MAX_TRIANGLES, _GENERATE_HULL_MAX_VERTICES,
|
||||||
|
int(workload.triangles), int(workload.vertices)])
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
static func _auto_generate_shape(mesh: Mesh) -> String:
|
||||||
|
if mesh is SphereMesh:
|
||||||
|
return "sphere"
|
||||||
|
if mesh is CapsuleMesh:
|
||||||
|
return "capsule"
|
||||||
|
if mesh is CylinderMesh:
|
||||||
|
return "cylinder"
|
||||||
|
return "box"
|
||||||
|
|
||||||
|
|
||||||
## Resolve one mesh and capture everything the apply step must find unchanged.
|
## Resolve one mesh and capture everything the apply step must find unchanged.
|
||||||
static func _plan_generate_mesh(
|
static func _plan_generate_mesh(
|
||||||
mesh_path: String, scene_file: String, scene_root: Node, shape_type: String
|
mesh_path: String, scene_file: String, scene_root: Node, shape_type: String, reparent_mesh: bool
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file)
|
var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file)
|
||||||
if resolved.has("error"):
|
if resolved.has("error"):
|
||||||
@@ -181,7 +274,11 @@ static func _plan_generate_mesh(
|
|||||||
"Node at %s is %s — must be MeshInstance3D" % [mesh_path, node.get_class()]
|
"Node at %s is %s — must be MeshInstance3D" % [mesh_path, node.get_class()]
|
||||||
)
|
)
|
||||||
var mesh := node as MeshInstance3D
|
var mesh := node as MeshInstance3D
|
||||||
|
if mesh.has_meta(Refresh.MARKER):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Mesh at %s already has a collider sibling or generated collider provenance; use overwrite=true to refresh its shape" % mesh_path)
|
||||||
var parent := mesh.get_parent()
|
var parent := mesh.get_parent()
|
||||||
|
if parent != null and parent.has_meta(Refresh.MARKER):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot create beneath a generated body at %s: missing or inconsistent source provenance" % mesh_path)
|
||||||
if parent == null:
|
if parent == null:
|
||||||
return ErrorCodes.make(
|
return ErrorCodes.make(
|
||||||
ErrorCodes.INVALID_PARAMS,
|
ErrorCodes.INVALID_PARAMS,
|
||||||
@@ -192,6 +289,13 @@ static func _plan_generate_mesh(
|
|||||||
ErrorCodes.RESOURCE_NOT_FOUND,
|
ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
"MeshInstance3D at %s has no mesh resource — there are no bounds to fit" % mesh_path
|
"MeshInstance3D at %s has no mesh resource — there are no bounds to fit" % mesh_path
|
||||||
)
|
)
|
||||||
|
var auto_shape := shape_type == "auto"
|
||||||
|
if auto_shape:
|
||||||
|
shape_type = _auto_generate_shape(mesh.mesh)
|
||||||
|
if shape_type == "convex" or shape_type == "trimesh":
|
||||||
|
var error := _validate_hull_workload(mesh.mesh, mesh_path, shape_type)
|
||||||
|
if not error.is_empty():
|
||||||
|
return error
|
||||||
var collider_name := mesh.name + _GENERATE_COLLIDER_SUFFIX
|
var collider_name := mesh.name + _GENERATE_COLLIDER_SUFFIX
|
||||||
var existing := parent.get_node_or_null(NodePath(collider_name))
|
var existing := parent.get_node_or_null(NodePath(collider_name))
|
||||||
if existing != null:
|
if existing != null:
|
||||||
@@ -200,10 +304,18 @@ static func _plan_generate_mesh(
|
|||||||
"MeshInstance3D at %s already has a collider sibling at %s — remove or rename it first"
|
"MeshInstance3D at %s already has a collider sibling at %s — remove or rename it first"
|
||||||
% [mesh_path, McpScenePath.from_node(existing, scene_root)]
|
% [mesh_path, McpScenePath.from_node(existing, scene_root)]
|
||||||
)
|
)
|
||||||
|
## A top-level mesh ignores its parent's transform, so wrapping it under a
|
||||||
|
## body would not couple it to that body.
|
||||||
|
if reparent_mesh and mesh.top_level:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"MeshInstance3D at %s has top_level enabled — reparent_mesh cannot couple it to the generated body" % mesh_path
|
||||||
|
)
|
||||||
## A sibling in parent space inherits the parent chain's scale exactly like
|
## A sibling in parent space inherits the parent chain's scale exactly like
|
||||||
## the mesh does, so its box matches the visual; but Godot cannot scale a
|
## the mesh does, so its box matches the visual; but Godot cannot scale a
|
||||||
## sphere, capsule or cylinder non-uniformly (the warning it prints is a
|
## sphere, capsule, cylinder or concave shape non-uniformly (the warning it
|
||||||
## deformed collider). A top-level mesh ignores its parent's transform.
|
## prints is a deformed collider). A top-level mesh ignores its parent's
|
||||||
|
## transform.
|
||||||
if not mesh.top_level and shape_type != "box" and parent is Node3D:
|
if not mesh.top_level and shape_type != "box" and parent is Node3D:
|
||||||
var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale()
|
var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale()
|
||||||
var spread := absf(parent_scale.x - parent_scale.y)
|
var spread := absf(parent_scale.x - parent_scale.y)
|
||||||
@@ -221,6 +333,9 @@ static func _plan_generate_mesh(
|
|||||||
Basis(source_transform.basis.get_rotation_quaternion()),
|
Basis(source_transform.basis.get_rotation_quaternion()),
|
||||||
source_transform.origin,
|
source_transform.origin,
|
||||||
)
|
)
|
||||||
|
## The mesh's own scale and offset, relative to the scale-free body — the
|
||||||
|
## convex/trimesh shapes bake this scale into their vertices, and the
|
||||||
|
## reparent option keeps the mesh's world transform by assigning it.
|
||||||
var mesh_to_body := body_transform.affine_inverse() * source_transform
|
var mesh_to_body := body_transform.affine_inverse() * source_transform
|
||||||
var bounds: AABB = mesh_to_body * mesh.get_aabb()
|
var bounds: AABB = mesh_to_body * mesh.get_aabb()
|
||||||
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0 or bounds.size.z <= 0.0:
|
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0 or bounds.size.z <= 0.0:
|
||||||
@@ -231,11 +346,17 @@ static func _plan_generate_mesh(
|
|||||||
return {"plan": {
|
return {"plan": {
|
||||||
"mesh": mesh,
|
"mesh": mesh,
|
||||||
"mesh_path": mesh_path,
|
"mesh_path": mesh_path,
|
||||||
|
"mesh_name": mesh.name,
|
||||||
|
"auto_shape": auto_shape,
|
||||||
|
"shape_type": shape_type,
|
||||||
|
"source_mesh": mesh.mesh,
|
||||||
|
"source_bounds": mesh.get_aabb(),
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
"collider_name": collider_name,
|
"collider_name": collider_name,
|
||||||
"top_level": mesh.top_level,
|
"top_level": mesh.top_level,
|
||||||
"source_transform": source_transform,
|
"source_transform": source_transform,
|
||||||
"body_transform": body_transform,
|
"body_transform": body_transform,
|
||||||
|
"mesh_to_body": mesh_to_body,
|
||||||
"bounds": bounds,
|
"bounds": bounds,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -245,15 +366,23 @@ static func _plan_generate_mesh(
|
|||||||
## request can never apply plan-time state to a mesh that moved, was
|
## request can never apply plan-time state to a mesh that moved, was
|
||||||
## reparented, lost its mesh, gained a collider, or went away meanwhile.
|
## reparented, lost its mesh, gained a collider, or went away meanwhile.
|
||||||
static func _plan_stale_reason(plan: Dictionary) -> String:
|
static func _plan_stale_reason(plan: Dictionary) -> String:
|
||||||
var mesh: MeshInstance3D = plan.mesh
|
## Keep the captured references untyped until validity is known: assigning
|
||||||
|
## a freed Object to a typed local raises before is_instance_valid() can
|
||||||
|
## inspect it, and a mesh, its parent, or the scene root can be freed
|
||||||
|
## between the frame that planned it and the frame that applies it.
|
||||||
|
var mesh_ref = plan.mesh
|
||||||
## A node taken out of the tree and queued to free is still a valid
|
## A node taken out of the tree and queued to free is still a valid
|
||||||
## instance for the rest of the frame; not being in the tree is what
|
## instance for the rest of the frame; not being in the tree is what
|
||||||
## "removed" means here.
|
## "removed" means here.
|
||||||
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
|
if not is_instance_valid(mesh_ref) or not mesh_ref.is_inside_tree():
|
||||||
return "was removed"
|
return "was removed"
|
||||||
var parent: Node = plan.parent
|
var mesh: MeshInstance3D = mesh_ref
|
||||||
if not is_instance_valid(parent) or mesh.get_parent() != parent:
|
if mesh.name != plan.mesh_name:
|
||||||
|
return "was renamed"
|
||||||
|
var parent_ref = plan.parent
|
||||||
|
if not is_instance_valid(parent_ref) or mesh.get_parent() != parent_ref:
|
||||||
return "was reparented"
|
return "was reparented"
|
||||||
|
var parent: Node = parent_ref
|
||||||
if mesh.top_level != bool(plan.top_level):
|
if mesh.top_level != bool(plan.top_level):
|
||||||
return "changed its top_level setting"
|
return "changed its top_level setting"
|
||||||
var source_transform := mesh.global_transform if mesh.top_level else mesh.transform
|
var source_transform := mesh.global_transform if mesh.top_level else mesh.transform
|
||||||
@@ -261,51 +390,202 @@ static func _plan_stale_reason(plan: Dictionary) -> String:
|
|||||||
return "moved"
|
return "moved"
|
||||||
if mesh.mesh == null:
|
if mesh.mesh == null:
|
||||||
return "lost its mesh resource"
|
return "lost its mesh resource"
|
||||||
|
var auto_stale := _auto_source_stale_reason(mesh, plan)
|
||||||
|
if not auto_stale.is_empty():
|
||||||
|
return auto_stale
|
||||||
if parent.get_node_or_null(NodePath(str(plan.collider_name))) != null:
|
if parent.get_node_or_null(NodePath(str(plan.collider_name))) != null:
|
||||||
return "gained a collider sibling"
|
return "gained a collider sibling"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
static func _auto_source_stale_reason(mesh: MeshInstance3D, snapshot: Dictionary) -> String:
|
||||||
|
if bool(snapshot.get("auto_shape", false)):
|
||||||
|
if mesh.mesh != snapshot.source_mesh:
|
||||||
|
return "changed its mesh resource"
|
||||||
|
if not mesh.get_aabb().is_equal_approx(snapshot.source_bounds):
|
||||||
|
return "changed its mesh bounds"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
## Build one detached body/collision pair from a prevalidated mesh plan.
|
## Build one detached body/collision pair from a prevalidated mesh plan.
|
||||||
|
## Returns `{error: ...}` when the mesh cannot produce the requested shape
|
||||||
|
## (the engine refuses a degenerate hull).
|
||||||
static func _create_generated_entry(
|
static func _create_generated_entry(
|
||||||
plan: Dictionary, shape_type: String, body_type: String
|
plan: Dictionary, shape_type: String, body_type: String, reparent_mesh: bool
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var mesh: MeshInstance3D = plan.mesh
|
var mesh: MeshInstance3D = plan.mesh
|
||||||
|
if shape_type == "convex" or shape_type == "trimesh":
|
||||||
|
var error := _validate_hull_workload(mesh.mesh, str(plan.mesh_path), shape_type)
|
||||||
|
if not error.is_empty():
|
||||||
|
return error
|
||||||
|
var bounds: AABB = plan.bounds
|
||||||
|
var shape: Shape3D
|
||||||
|
if shape_type == "convex" or shape_type == "trimesh":
|
||||||
|
## Hull and concave shapes come from the mesh's own vertices, with the
|
||||||
|
## mesh's scale baked in so the collision node needs no scale of its own.
|
||||||
|
shape = _fit_mesh_shape(mesh, shape_type, plan.mesh_to_body)
|
||||||
|
if shape == null:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"MeshInstance3D at %s could not produce a %s shape — the mesh geometry is degenerate"
|
||||||
|
% [str(plan.mesh_path), shape_type]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shape = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type])
|
||||||
|
_apply_shape_size(shape, shape_type, {"aabb": bounds}, true)
|
||||||
var body: CollisionObject3D = ClassDB.instantiate(_GENERATED_BODY_CLASSES[body_type])
|
var body: CollisionObject3D = ClassDB.instantiate(_GENERATED_BODY_CLASSES[body_type])
|
||||||
body.name = str(plan.collider_name)
|
body.name = str(plan.collider_name)
|
||||||
body.top_level = bool(plan.top_level)
|
body.top_level = bool(plan.top_level)
|
||||||
body.transform = plan.body_transform
|
body.transform = plan.body_transform
|
||||||
var collision := CollisionShape3D.new()
|
var collision := CollisionShape3D.new()
|
||||||
collision.name = "CollisionShape3D"
|
collision.name = "CollisionShape3D"
|
||||||
var bounds: AABB = plan.bounds
|
if shape_type != "convex" and shape_type != "trimesh":
|
||||||
collision.position = bounds.get_center()
|
collision.position = _snap_tiny(bounds.get_center())
|
||||||
var shape: Shape3D = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type])
|
|
||||||
_apply_shape_size(shape, shape_type, {"aabb": bounds}, true)
|
|
||||||
collision.shape = shape
|
collision.shape = shape
|
||||||
body.add_child(collision)
|
body.add_child(collision)
|
||||||
return {
|
var entry := {
|
||||||
"mesh": mesh,
|
"mesh": mesh,
|
||||||
|
"mesh_path": str(plan.mesh_path),
|
||||||
|
"mesh_name": mesh.name,
|
||||||
|
"body_name": body.name,
|
||||||
|
"shape_type": shape_type,
|
||||||
|
"auto_shape": bool(plan.get("auto_shape", false)),
|
||||||
|
"source_mesh": plan.get("source_mesh"),
|
||||||
|
"source_bounds": plan.get("source_bounds"),
|
||||||
|
"mesh_local_transform": plan.mesh_to_body,
|
||||||
"parent": plan.parent,
|
"parent": plan.parent,
|
||||||
"body": body,
|
"body": body,
|
||||||
"collision": collision,
|
"collision": collision,
|
||||||
|
"reparent_mesh": reparent_mesh,
|
||||||
}
|
}
|
||||||
|
if reparent_mesh:
|
||||||
|
## Captured at apply time, right before the mesh moves under the body.
|
||||||
|
entry["mesh_parent"] = mesh.get_parent()
|
||||||
|
entry["mesh_index"] = mesh.get_index()
|
||||||
|
entry["mesh_transform"] = mesh.transform
|
||||||
|
entry["mesh_owner"] = mesh.owner
|
||||||
|
Refresh.prepare_markers(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
## Derive a ConvexPolygonShape3D/ConcavePolygonShape3D from the mesh and bake
|
||||||
|
## the mesh's own scale into its vertices, so the collision node keeps an
|
||||||
|
## identity transform and Godot never has to scale a hull or concave shape.
|
||||||
|
static func _fit_mesh_shape(mesh: MeshInstance3D, shape_type: String, mesh_to_body: Transform3D) -> Shape3D:
|
||||||
|
var shape: Shape3D = (
|
||||||
|
mesh.mesh.create_convex_shape() if shape_type == "convex" else mesh.mesh.create_trimesh_shape()
|
||||||
|
)
|
||||||
|
if shape == null:
|
||||||
|
return null
|
||||||
|
if mesh_to_body.is_equal_approx(Transform3D.IDENTITY):
|
||||||
|
return shape
|
||||||
|
if shape is ConvexPolygonShape3D:
|
||||||
|
var points := (shape as ConvexPolygonShape3D).points
|
||||||
|
for index in points.size():
|
||||||
|
points[index] = mesh_to_body * points[index]
|
||||||
|
(shape as ConvexPolygonShape3D).points = points
|
||||||
|
else:
|
||||||
|
var faces := (shape as ConcavePolygonShape3D).get_faces()
|
||||||
|
for index in faces.size():
|
||||||
|
faces[index] = mesh_to_body * faces[index]
|
||||||
|
if mesh_to_body.basis.determinant() < 0.0:
|
||||||
|
## A mirrored scale reverses triangle winding, and a
|
||||||
|
## ConcavePolygonShape3D collides with front faces only.
|
||||||
|
for index in range(0, faces.size(), 3):
|
||||||
|
var swapped := faces[index + 1]
|
||||||
|
faces[index + 1] = faces[index + 2]
|
||||||
|
faces[index + 2] = swapped
|
||||||
|
(shape as ConcavePolygonShape3D).set_faces(faces)
|
||||||
|
return shape
|
||||||
|
|
||||||
|
|
||||||
|
## Float noise from AABB centering is snapped out of a generated collision
|
||||||
|
## offset (see `_GENERATE_SNAP_EPSILON`).
|
||||||
|
static func _snap_tiny(value: Vector3) -> Vector3:
|
||||||
|
return Vector3(_snap_tiny_component(value.x), _snap_tiny_component(value.y), _snap_tiny_component(value.z))
|
||||||
|
|
||||||
|
|
||||||
|
static func _snap_tiny_component(component: float) -> float:
|
||||||
|
return 0.0 if absf(component) < _GENERATE_SNAP_EPSILON else component
|
||||||
|
|
||||||
|
|
||||||
|
## Why an already-applied entry no longer matches the scene, or "" when every
|
||||||
|
## generated body is still present under its planned parent. Checked right
|
||||||
|
## before the batch is committed: a node freed or reparented while the
|
||||||
|
## remaining entries were applied must fail the request and roll back the
|
||||||
|
## surviving bodies, not abort the commit and the reply with freed-instance
|
||||||
|
## errors that leave an uncommitted body behind.
|
||||||
|
static func _applied_stale_reason(created_nodes: Array[Dictionary]) -> String:
|
||||||
|
for entry in created_nodes:
|
||||||
|
## Keep these untyped until validity is known: a typed assignment raises
|
||||||
|
## on a freed Object before is_instance_valid() can inspect it.
|
||||||
|
var mesh = entry.mesh
|
||||||
|
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
|
||||||
|
return "%s was removed" % str(entry.mesh_path)
|
||||||
|
if mesh.name != entry.mesh_name:
|
||||||
|
return "%s was renamed" % str(entry.mesh_path)
|
||||||
|
var auto_stale := _auto_source_stale_reason(mesh, entry)
|
||||||
|
if not auto_stale.is_empty():
|
||||||
|
return "%s %s" % [str(entry.mesh_path), auto_stale]
|
||||||
|
var parent = entry.parent
|
||||||
|
if not is_instance_valid(parent):
|
||||||
|
return "%s was reparented" % str(entry.mesh_path)
|
||||||
|
## A wrapped mesh is expected under the generated body, not under the
|
||||||
|
## parent captured at plan time. Keep the conditional untyped: a typed
|
||||||
|
## assignment raises on a freed body before the validity check.
|
||||||
|
var mesh_parent = entry.body if bool(entry.get("reparent_mesh", false)) else parent
|
||||||
|
if not is_instance_valid(mesh_parent) or mesh.get_parent() != mesh_parent:
|
||||||
|
return "%s was reparented" % str(entry.mesh_path)
|
||||||
|
var body = entry.body
|
||||||
|
if not is_instance_valid(body) or not body.is_inside_tree():
|
||||||
|
return "the generated body for %s was removed" % str(entry.mesh_path)
|
||||||
|
if body.name != entry.body_name:
|
||||||
|
return "the generated body for %s was renamed" % str(entry.mesh_path)
|
||||||
|
if body.get_parent() != parent:
|
||||||
|
return "the generated body for %s was reparented" % str(entry.mesh_path)
|
||||||
|
var collision = entry.collision
|
||||||
|
if not is_instance_valid(collision) or collision.get_parent() != body:
|
||||||
|
return "the generated collision shape for %s was removed" % str(entry.mesh_path)
|
||||||
|
if collision.name != &"CollisionShape3D":
|
||||||
|
return "the generated collision shape for %s was renamed" % str(entry.mesh_path)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
## Record all generated nodes as one undo action, optionally executing it.
|
## Record all generated nodes as one undo action, optionally executing it.
|
||||||
|
## With `reparent_mesh` the mesh moves under the body, so the do (redo) and
|
||||||
|
## undo methods carry it across the two layouts; undo restores the mesh's
|
||||||
|
## original parent, index, transform and owner before the body is detached.
|
||||||
static func _commit_generated_action(
|
static func _commit_generated_action(
|
||||||
created_nodes: Array[Dictionary],
|
created_nodes: Array[Dictionary],
|
||||||
scene_root: Node,
|
scene_root: Node,
|
||||||
undo_redo: EditorUndoRedoManager,
|
undo_redo: EditorUndoRedoManager,
|
||||||
execute: bool,
|
execute: bool,
|
||||||
) -> void:
|
) -> void:
|
||||||
undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size())
|
undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size(), UndoRedo.MERGE_DISABLE, scene_root)
|
||||||
for entry in created_nodes:
|
for entry in created_nodes:
|
||||||
|
if bool(entry.get("refresh", false)):
|
||||||
|
undo_redo.add_do_method(ClassDB, "class_set_property", entry.collision, "shape", entry.new_shape)
|
||||||
|
undo_redo.add_do_method(ClassDB, "class_set_property", entry.collision, "transform", entry.new_transform)
|
||||||
|
undo_redo.add_undo_method(ClassDB, "class_set_property", entry.collision, "shape", entry.old_shape)
|
||||||
|
undo_redo.add_undo_method(ClassDB, "class_set_property", entry.collision, "transform", entry.old_transform)
|
||||||
|
continue
|
||||||
|
Refresh.record_marker_action(entry, undo_redo, execute)
|
||||||
var parent: Node = entry.parent
|
var parent: Node = entry.parent
|
||||||
var body: CollisionObject3D = entry.body
|
var body: CollisionObject3D = entry.body
|
||||||
var collision: CollisionShape3D = entry.collision
|
var collision: CollisionShape3D = entry.collision
|
||||||
undo_redo.add_do_method(parent, "add_child", body, true)
|
undo_redo.add_do_method(parent, "add_child", body, true)
|
||||||
undo_redo.add_do_method(body, "set_owner", scene_root)
|
undo_redo.add_do_method(body, "set_owner", scene_root)
|
||||||
undo_redo.add_do_method(collision, "set_owner", scene_root)
|
undo_redo.add_do_method(collision, "set_owner", scene_root)
|
||||||
|
if bool(entry.reparent_mesh):
|
||||||
|
var mesh: MeshInstance3D = entry.mesh
|
||||||
|
var mesh_parent: Node = entry.mesh_parent
|
||||||
|
undo_redo.add_do_method(mesh, "reparent", body, true)
|
||||||
|
undo_redo.add_do_method(mesh, "set_owner", scene_root)
|
||||||
|
undo_redo.add_do_method(mesh, "set_transform", entry.mesh_local_transform)
|
||||||
|
undo_redo.add_undo_method(mesh, "reparent", mesh_parent, true)
|
||||||
|
undo_redo.add_undo_method(mesh_parent, "move_child", mesh, int(entry.mesh_index))
|
||||||
|
undo_redo.add_undo_method(mesh, "set_transform", entry.mesh_transform)
|
||||||
|
undo_redo.add_undo_method(mesh, "set_owner", entry.mesh_owner)
|
||||||
undo_redo.add_do_reference(body)
|
undo_redo.add_do_reference(body)
|
||||||
undo_redo.add_undo_method(parent, "remove_child", body)
|
undo_redo.add_undo_method(parent, "remove_child", body)
|
||||||
undo_redo.commit_action(execute)
|
undo_redo.commit_action(execute)
|
||||||
@@ -313,7 +593,7 @@ static func _commit_generated_action(
|
|||||||
|
|
||||||
## Build the public response after every body is present in the edited scene.
|
## Build the public response after every body is present in the edited scene.
|
||||||
static func _generated_response(
|
static func _generated_response(
|
||||||
created_nodes: Array[Dictionary], scene_root: Node, shape_type: String, body_type: String
|
created_nodes: Array[Dictionary], scene_root: Node, body_type: String
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
var created: Array[Dictionary] = []
|
var created: Array[Dictionary] = []
|
||||||
for entry in created_nodes:
|
for entry in created_nodes:
|
||||||
@@ -321,8 +601,9 @@ static func _generated_response(
|
|||||||
"mesh_path": McpScenePath.from_node(entry.mesh, scene_root),
|
"mesh_path": McpScenePath.from_node(entry.mesh, scene_root),
|
||||||
"body_path": McpScenePath.from_node(entry.body, scene_root),
|
"body_path": McpScenePath.from_node(entry.body, scene_root),
|
||||||
"shape_path": McpScenePath.from_node(entry.collision, scene_root),
|
"shape_path": McpScenePath.from_node(entry.collision, scene_root),
|
||||||
"shape_type": shape_type,
|
"shape_type": str(entry.shape_type),
|
||||||
"body_type": body_type,
|
"body_type": body_type,
|
||||||
|
"operation": "refresh" if bool(entry.get("refresh", false)) else "create",
|
||||||
})
|
})
|
||||||
return {"data": {"created": created, "undoable": true}}
|
return {"data": {"created": created, "undoable": true}}
|
||||||
|
|
||||||
@@ -335,6 +616,7 @@ static func _generate_job(
|
|||||||
validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String
|
validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String
|
||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
return {
|
return {
|
||||||
|
"refresh_worker": Refresh.new(),
|
||||||
"validated": validated,
|
"validated": validated,
|
||||||
"undo_redo": undo_redo,
|
"undo_redo": undo_redo,
|
||||||
"connection": connection,
|
"connection": connection,
|
||||||
@@ -343,6 +625,7 @@ static func _generate_job(
|
|||||||
"index": 0,
|
"index": 0,
|
||||||
"plans": [],
|
"plans": [],
|
||||||
"created": [],
|
"created": [],
|
||||||
|
"committed": false,
|
||||||
"result": {},
|
"result": {},
|
||||||
"started_ms": Time.get_ticks_msec(),
|
"started_ms": Time.get_ticks_msec(),
|
||||||
}
|
}
|
||||||
@@ -354,6 +637,8 @@ static func _generate_job(
|
|||||||
static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
|
static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
|
||||||
if str(job.phase) == "done":
|
if str(job.phase) == "done":
|
||||||
return true
|
return true
|
||||||
|
if bool(job.validated.get("overwrite", false)):
|
||||||
|
return job.refresh_worker.step(job, budget_usec)
|
||||||
var validated: Dictionary = job.validated
|
var validated: Dictionary = job.validated
|
||||||
var connection = job.connection
|
var connection = job.connection
|
||||||
if connection != null:
|
if connection != null:
|
||||||
@@ -368,12 +653,16 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
|
|||||||
ErrorCodes.DEFERRED_TIMEOUT,
|
ErrorCodes.DEFERRED_TIMEOUT,
|
||||||
"physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS,
|
"physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS,
|
||||||
))
|
))
|
||||||
var scene_root: Node = validated.scene_root
|
## Same freed-instance rule as the plan check: a scene root freed while the
|
||||||
if not is_instance_valid(scene_root) or EditorInterface.get_edited_scene_root() != scene_root:
|
## job was in flight must fail this step, not error on a typed assignment
|
||||||
|
## and leave the job retrying until the dispatcher timeout.
|
||||||
|
var scene_root_ref = validated.scene_root
|
||||||
|
if not is_instance_valid(scene_root_ref) or EditorInterface.get_edited_scene_root() != scene_root_ref:
|
||||||
return _generate_fail(job, ErrorCodes.make(
|
return _generate_fail(job, ErrorCodes.make(
|
||||||
ErrorCodes.EDITED_SCENE_MISMATCH,
|
ErrorCodes.EDITED_SCENE_MISMATCH,
|
||||||
"The edited scene changed while physics shapes were generated",
|
"The edited scene changed while physics shapes were generated",
|
||||||
))
|
))
|
||||||
|
var scene_root: Node = scene_root_ref
|
||||||
## The budget is checked before each item after the first, so every step
|
## The budget is checked before each item after the first, so every step
|
||||||
## makes progress and a phase that just finished its last item moves on.
|
## makes progress and a phase that just finished its last item moves on.
|
||||||
var frame_start := Time.get_ticks_usec()
|
var frame_start := Time.get_ticks_usec()
|
||||||
@@ -387,7 +676,8 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
|
|||||||
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec:
|
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec:
|
||||||
return false
|
return false
|
||||||
var planned := _plan_generate_mesh(
|
var planned := _plan_generate_mesh(
|
||||||
str(paths[int(job.index)]), str(validated.scene_file), scene_root, str(validated.shape_type)
|
str(paths[int(job.index)]), str(validated.scene_file), scene_root,
|
||||||
|
str(validated.shape_type), bool(validated.reparent_mesh)
|
||||||
)
|
)
|
||||||
if planned.has("error"):
|
if planned.has("error"):
|
||||||
return _generate_fail(job, planned)
|
return _generate_fail(job, planned)
|
||||||
@@ -407,21 +697,47 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
|
|||||||
"MeshInstance3D at %s %s while physics shapes were generated; nothing was changed"
|
"MeshInstance3D at %s %s while physics shapes were generated; nothing was changed"
|
||||||
% [str(plan.mesh_path), stale],
|
% [str(plan.mesh_path), stale],
|
||||||
))
|
))
|
||||||
var entry := _create_generated_entry(plan, str(validated.shape_type), str(validated.body_type))
|
var entry := _create_generated_entry(
|
||||||
|
plan, str(plan.shape_type), str(validated.body_type), bool(validated.reparent_mesh)
|
||||||
|
)
|
||||||
|
if entry.has("error"):
|
||||||
|
return _generate_fail(job, entry)
|
||||||
var parent: Node = plan.parent
|
var parent: Node = plan.parent
|
||||||
parent.add_child(entry.body, true)
|
parent.add_child(entry.body, true)
|
||||||
entry.body.set_owner(scene_root)
|
entry.body.set_owner(scene_root)
|
||||||
entry.collision.set_owner(scene_root)
|
entry.collision.set_owner(scene_root)
|
||||||
|
if bool(validated.reparent_mesh):
|
||||||
|
## `add_child` refuses a node that already has a parent, so the
|
||||||
|
## wrap moves the mesh with `reparent` and then pins the exact
|
||||||
|
## local transform (reparent preserves the world transform).
|
||||||
|
entry.mesh.reparent(entry.body, true)
|
||||||
|
entry.mesh.set_owner(scene_root)
|
||||||
|
entry.mesh.transform = entry.mesh_local_transform
|
||||||
job.created.append(entry)
|
job.created.append(entry)
|
||||||
job.index = int(job.index) + 1
|
job.index = int(job.index) + 1
|
||||||
work += 1
|
work += 1
|
||||||
## The nodes are already in the scene, so commit with execute=false: one
|
## Revalidate every applied entry before the batch is committed: a mesh,
|
||||||
## atomic undo/redo action without replaying every mutation now.
|
## parent, body or collision freed or reparented while the remaining entries
|
||||||
|
## were applied must fail the request and roll back the surviving bodies
|
||||||
|
## instead of erroring out of the commit and the reply.
|
||||||
var created: Array[Dictionary] = []
|
var created: Array[Dictionary] = []
|
||||||
created.assign(job.created)
|
created.assign(job.created)
|
||||||
|
var applied_stale := _applied_stale_reason(created)
|
||||||
|
if not applied_stale.is_empty():
|
||||||
|
var code: String = (
|
||||||
|
ErrorCodes.NODE_NOT_FOUND if applied_stale.ends_with("was removed")
|
||||||
|
else ErrorCodes.EDITED_SCENE_MISMATCH
|
||||||
|
)
|
||||||
|
return _generate_fail(job, ErrorCodes.make(
|
||||||
|
code,
|
||||||
|
"Scene changed while physics shapes were applied: %s; nothing was changed" % applied_stale,
|
||||||
|
))
|
||||||
|
## The nodes are already in the scene, so commit with execute=false: one
|
||||||
|
## atomic undo/redo action without replaying every mutation now.
|
||||||
_commit_generated_action(created, scene_root, job.undo_redo, false)
|
_commit_generated_action(created, scene_root, job.undo_redo, false)
|
||||||
|
job.committed = true
|
||||||
job.result = _generated_response(
|
job.result = _generated_response(
|
||||||
created, scene_root, str(validated.shape_type), str(validated.body_type)
|
created, scene_root, str(validated.body_type)
|
||||||
)
|
)
|
||||||
job.phase = "done"
|
job.phase = "done"
|
||||||
return true
|
return true
|
||||||
@@ -436,17 +752,58 @@ static func _generate_fail(job: Dictionary, error: Dictionary) -> bool:
|
|||||||
|
|
||||||
## Remove and free every body this job added; none is in an undo action yet.
|
## Remove and free every body this job added; none is in an undo action yet.
|
||||||
static func _generate_rollback(job: Dictionary) -> void:
|
static func _generate_rollback(job: Dictionary) -> void:
|
||||||
|
## Once the undo action owns these bodies they are a completed user change,
|
||||||
|
## even if the transport disappears before its reply can be delivered.
|
||||||
|
if bool(job.get("committed", false)):
|
||||||
|
return
|
||||||
|
if bool(job.validated.get("overwrite", false)):
|
||||||
|
job.refresh_worker.cleanup(job)
|
||||||
|
return
|
||||||
for entry in job.created:
|
for entry in job.created:
|
||||||
var body: Node = entry.body
|
## Keep this untyped until validity is known: assigning a freed Object to
|
||||||
|
## a typed Node local raises before is_instance_valid() can inspect it.
|
||||||
|
var body = entry.body
|
||||||
if not is_instance_valid(body):
|
if not is_instance_valid(body):
|
||||||
continue
|
continue
|
||||||
var parent := body.get_parent()
|
if bool(entry.get("reparent_mesh", false)):
|
||||||
|
_restore_reparented_mesh(entry)
|
||||||
|
var parent: Node = body.get_parent()
|
||||||
if parent != null:
|
if parent != null:
|
||||||
parent.remove_child(body)
|
parent.remove_child(body)
|
||||||
body.free()
|
body.free()
|
||||||
job.created.clear()
|
job.created.clear()
|
||||||
|
|
||||||
|
|
||||||
|
## Move a mesh back out of its generated body before that body is freed —
|
||||||
|
## `free()` would otherwise take the mesh with it. Parent, index, transform
|
||||||
|
## and owner are restored exactly.
|
||||||
|
static func _restore_reparented_mesh(entry: Dictionary) -> void:
|
||||||
|
var mesh = entry.mesh
|
||||||
|
if not is_instance_valid(mesh):
|
||||||
|
return
|
||||||
|
if mesh.get_parent() != entry.body:
|
||||||
|
return
|
||||||
|
## Keep this untyped until validity is known: assigning a freed Object to
|
||||||
|
## a typed Node local raises before is_instance_valid() can inspect it.
|
||||||
|
var mesh_parent = entry.mesh_parent
|
||||||
|
if not is_instance_valid(mesh_parent):
|
||||||
|
## The captured parent was freed while the deferred job owned the
|
||||||
|
## mesh. Reattach it to wherever the body now lives — or at least
|
||||||
|
## detach it — so the rollback's `free()` cannot take it with it.
|
||||||
|
var fallback = entry.body.get_parent()
|
||||||
|
if is_instance_valid(fallback):
|
||||||
|
mesh.reparent(fallback, true)
|
||||||
|
else:
|
||||||
|
entry.body.remove_child(mesh)
|
||||||
|
return
|
||||||
|
mesh.reparent(mesh_parent, true)
|
||||||
|
var index := int(entry.mesh_index)
|
||||||
|
if index < mesh_parent.get_child_count():
|
||||||
|
mesh_parent.move_child(mesh, index)
|
||||||
|
mesh.transform = entry.mesh_transform
|
||||||
|
mesh.set_owner(entry.mesh_owner)
|
||||||
|
|
||||||
|
|
||||||
## Check that the connection and deferred dispatcher entry are both live.
|
## Check that the connection and deferred dispatcher entry are both live.
|
||||||
static func _deferred_request_pending(connection, request_id: String) -> bool:
|
static func _deferred_request_pending(connection, request_id: String) -> bool:
|
||||||
if not is_instance_valid(connection):
|
if not is_instance_valid(connection):
|
||||||
@@ -457,19 +814,59 @@ static func _deferred_request_pending(connection, request_id: String) -> bool:
|
|||||||
|
|
||||||
## Drive a deferred job one editor frame at a time and reply when it ends.
|
## Drive a deferred job one editor frame at a time and reply when it ends.
|
||||||
## `send_deferred_response` itself drops a reply whose request is gone.
|
## `send_deferred_response` itself drops a reply whose request is gone.
|
||||||
static func _drive_generate_job(job: Dictionary, connection) -> void:
|
static func _drive_generate_job(job: Dictionary, connection, frame_signal: Variant = null) -> void:
|
||||||
|
var work_id := ScriptWork.begin("physics_shape_generate")
|
||||||
|
job["work_id"] = work_id
|
||||||
if not is_instance_valid(connection):
|
if not is_instance_valid(connection):
|
||||||
|
_cancel_generate_job(job)
|
||||||
|
return
|
||||||
|
if not connection.is_inside_tree():
|
||||||
|
_cancel_generate_job(job)
|
||||||
return
|
return
|
||||||
var tree: SceneTree = connection.get_tree()
|
var tree: SceneTree = connection.get_tree()
|
||||||
if tree == null:
|
if tree == null:
|
||||||
|
_cancel_generate_job(job)
|
||||||
return
|
return
|
||||||
|
var resume_signal: Signal = frame_signal if frame_signal is Signal else tree.process_frame
|
||||||
## The first yield lets the dispatcher register the deferred request before
|
## The first yield lets the dispatcher register the deferred request before
|
||||||
## any validation error or successful result can be sent.
|
## any validation error or successful result can be sent. The abandoned-request
|
||||||
await tree.process_frame
|
## check belongs in `_generate_step`, after this yield: before the handler
|
||||||
while is_instance_valid(connection) and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC):
|
## returns its deferred sentinel the dispatcher has not registered the request
|
||||||
await tree.process_frame
|
## yet, so checking here would cancel every real call.
|
||||||
if is_instance_valid(connection) and not job.result.is_empty():
|
await resume_signal
|
||||||
|
## A connection that left the tree is detected here, not from `tree_exiting`:
|
||||||
|
## that signal runs inside the parent's `remove_child`, where a synchronous
|
||||||
|
## rollback's own `remove_child`/`free` is refused and then frees a still
|
||||||
|
## parented body, corrupting the scene tree.
|
||||||
|
while (
|
||||||
|
is_instance_valid(connection)
|
||||||
|
and connection.is_inside_tree()
|
||||||
|
and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC)
|
||||||
|
):
|
||||||
|
await resume_signal
|
||||||
|
if str(job.phase) != "done":
|
||||||
|
_generate_rollback(job)
|
||||||
|
job.phase = "done"
|
||||||
|
if is_instance_valid(connection) and connection.is_inside_tree() and not job.result.is_empty():
|
||||||
connection.send_deferred_response(str(job.request_id), job.result)
|
connection.send_deferred_response(str(job.request_id), job.result)
|
||||||
|
_finish_generate_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
## Cancel from request abandonment or a connection that left the tree.
|
||||||
|
## Completed actions are already committed, so rollback deliberately preserves them.
|
||||||
|
static func _cancel_generate_job(job: Dictionary) -> void:
|
||||||
|
if str(job.get("phase", "")) != "done":
|
||||||
|
_generate_rollback(job)
|
||||||
|
job.phase = "done"
|
||||||
|
_finish_generate_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
## Release the process-wide script-work lease once.
|
||||||
|
static func _finish_generate_job(job: Dictionary) -> void:
|
||||||
|
var work_id := int(job.get("work_id", 0))
|
||||||
|
if work_id != 0:
|
||||||
|
ScriptWork.finish(work_id)
|
||||||
|
job["work_id"] = 0
|
||||||
|
|
||||||
|
|
||||||
func autofit(params: Dictionary) -> Dictionary:
|
func autofit(params: Dictionary) -> Dictionary:
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
const MARKER := &"_godot_ai_physics_v1"
|
||||||
|
|
||||||
|
var _host: Script
|
||||||
|
var _watches: Array[Dictionary] = []
|
||||||
|
var _seen: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
static func prepare_markers(entry: Dictionary) -> void:
|
||||||
|
var name := str(ClassDB.class_get_property(entry.mesh, "name"))
|
||||||
|
var body_name := str(entry.body.name)
|
||||||
|
var wrapped := bool(entry.reparent_mesh)
|
||||||
|
entry["old_marker_present"] = entry.mesh.has_meta(MARKER)
|
||||||
|
entry["old_marker"] = entry.mesh.get_meta(MARKER) if entry.mesh.has_meta(MARKER) else null
|
||||||
|
entry["marker"] = {
|
||||||
|
"version": 1, "wrapped": wrapped,
|
||||||
|
"body": NodePath(".." if wrapped else "../" + body_name),
|
||||||
|
"collision": NodePath("../CollisionShape3D" if wrapped else "../" + body_name + "/CollisionShape3D"),
|
||||||
|
}
|
||||||
|
entry.body.set_meta(MARKER, {
|
||||||
|
"version": 1, "wrapped": wrapped,
|
||||||
|
"source": NodePath(name if wrapped else "../" + name),
|
||||||
|
"collision": NodePath("CollisionShape3D"),
|
||||||
|
})
|
||||||
|
entry.collision.set_meta(MARKER, {
|
||||||
|
"version": 1, "source": NodePath("../" + name if wrapped else "../../" + name),
|
||||||
|
"body": NodePath(".."),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
static func record_marker_action(entry: Dictionary, undo: EditorUndoRedoManager, execute: bool) -> void:
|
||||||
|
undo.add_do_method(entry.mesh, "set_meta", MARKER, entry.marker)
|
||||||
|
if bool(entry.old_marker_present):
|
||||||
|
undo.add_undo_method(entry.mesh, "set_meta", MARKER, entry.old_marker)
|
||||||
|
else:
|
||||||
|
undo.add_undo_method(entry.mesh, "remove_meta", MARKER)
|
||||||
|
if not execute:
|
||||||
|
entry.mesh.set_meta(MARKER, entry.marker)
|
||||||
|
|
||||||
|
|
||||||
|
static func _link(node: Node, marker: Variant, key: String) -> Node:
|
||||||
|
if marker is not Dictionary or typeof(marker.get("version")) != TYPE_INT or marker.version != 1 or typeof(marker.get(key)) != TYPE_NODE_PATH:
|
||||||
|
return null
|
||||||
|
return node.get_node_or_null(marker[key])
|
||||||
|
|
||||||
|
|
||||||
|
static func relationship(mesh: MeshInstance3D, root: Node, body_type: String, wrapped: bool) -> Dictionary:
|
||||||
|
var marker: Variant = mesh.get_meta(MARKER) if mesh.has_meta(MARKER) else {}
|
||||||
|
var body := _link(mesh, marker, "body")
|
||||||
|
var collision := _link(mesh, marker, "collision")
|
||||||
|
if body == null or collision == null or collision.get_class() != "CollisionShape3D":
|
||||||
|
return {}
|
||||||
|
var classes := {"static": "StaticBody3D", "area": "Area3D", "rigid": "RigidBody3D", "character": "CharacterBody3D"}
|
||||||
|
if body.get_class() != classes[body_type] or typeof(marker.get("wrapped")) != TYPE_BOOL or marker.wrapped != wrapped:
|
||||||
|
return {}
|
||||||
|
var body_marker: Variant = body.get_meta(MARKER) if body.has_meta(MARKER) else {}
|
||||||
|
var collision_marker: Variant = collision.get_meta(MARKER) if collision.has_meta(MARKER) else {}
|
||||||
|
if _link(body, body_marker, "source") != mesh or _link(body, body_marker, "collision") != collision:
|
||||||
|
return {}
|
||||||
|
if _link(collision, collision_marker, "source") != mesh or _link(collision, collision_marker, "body") != body:
|
||||||
|
return {}
|
||||||
|
if typeof(body_marker.get("wrapped")) != TYPE_BOOL or body_marker.wrapped != wrapped or collision.get_parent() != body:
|
||||||
|
return {}
|
||||||
|
if ClassDB.class_get_property(mesh, "owner") != root or ClassDB.class_get_property(body, "owner") != root or ClassDB.class_get_property(collision, "owner") != root:
|
||||||
|
return {}
|
||||||
|
if (wrapped and mesh.get_parent() != body) or (not wrapped and mesh.get_parent() != body.get_parent()):
|
||||||
|
return {}
|
||||||
|
return {"body": body, "collision": collision}
|
||||||
|
|
||||||
|
|
||||||
|
func _watch(resource: Resource, plan: Dictionary) -> void:
|
||||||
|
if resource == null:
|
||||||
|
return
|
||||||
|
var callback := func(): plan["resource_changed"] = true
|
||||||
|
resource.changed.connect(callback)
|
||||||
|
_watches.append({"resource": resource, "callback": callback})
|
||||||
|
|
||||||
|
|
||||||
|
func _disconnect() -> void:
|
||||||
|
for watch in _watches:
|
||||||
|
if watch.resource.changed.is_connected(watch.callback):
|
||||||
|
watch.resource.changed.disconnect(watch.callback)
|
||||||
|
_watches.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _plan(path: String, validated: Dictionary) -> Dictionary:
|
||||||
|
var resolved := McpNodeValidator.resolve_or_error(path, "paths", str(validated.scene_file))
|
||||||
|
if resolved.has("error"):
|
||||||
|
return resolved
|
||||||
|
var node: Node = resolved.node
|
||||||
|
if node is not MeshInstance3D or not node.has_meta(MARKER):
|
||||||
|
if node.get_parent() != null and node.get_parent().has_meta(MARKER):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot create beneath a generated body at %s: missing or inconsistent source provenance" % path)
|
||||||
|
var fresh: Dictionary = _host._plan_generate_mesh(path, validated.scene_file, validated.scene_root, validated.shape_type, validated.reparent_mesh)
|
||||||
|
if fresh.has("plan"):
|
||||||
|
fresh.plan["world_transform"] = ClassDB.class_get_property(node, "global_transform")
|
||||||
|
fresh.plan["source_owner"] = ClassDB.class_get_property(node, "owner")
|
||||||
|
return fresh
|
||||||
|
var mesh := node as MeshInstance3D
|
||||||
|
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
|
||||||
|
if linked.is_empty() or ClassDB.class_get_property(mesh, "mesh") == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: generated provenance, ownership, body type or topology does not match" % path)
|
||||||
|
var body := linked.body as CollisionObject3D
|
||||||
|
var collision := linked.collision as CollisionShape3D
|
||||||
|
if ClassDB.class_get_property(collision, "top_level"):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: collision top_level must remain false" % path)
|
||||||
|
var shape_type: String = _host._auto_generate_shape(ClassDB.class_get_property(mesh, "mesh")) if validated.shape_type == "auto" else validated.shape_type
|
||||||
|
var body_world: Transform3D = ClassDB.class_get_property(body, "global_transform")
|
||||||
|
var basis := body_world.basis
|
||||||
|
if absf(basis.determinant()) < 0.000001:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body transform is singular" % path)
|
||||||
|
var scale := basis.get_scale()
|
||||||
|
var orthogonal := absf(basis.x.normalized().dot(basis.y.normalized())) < 0.0001 and absf(basis.x.normalized().dot(basis.z.normalized())) < 0.0001 and absf(basis.y.normalized().dot(basis.z.normalized())) < 0.0001
|
||||||
|
if not orthogonal or (shape_type != "box" and not scale.abs().is_equal_approx(Vector3.ONE * absf(scale.x))):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: body scale/shear is unsupported for %s" % [path, shape_type])
|
||||||
|
var mesh_to_body: Transform3D = body_world.affine_inverse() * ClassDB.class_get_property(mesh, "global_transform")
|
||||||
|
var bounds := mesh_to_body * mesh.get_aabb()
|
||||||
|
if bounds.size.x <= 0 or bounds.size.y <= 0 or bounds.size.z <= 0:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot refresh %s: mesh has empty bounds" % path)
|
||||||
|
return {"plan": {
|
||||||
|
"refresh": true, "mesh": mesh, "mesh_path": path, "parent": mesh.get_parent(),
|
||||||
|
"body": body, "collision": collision, "shape_type": shape_type,
|
||||||
|
"reparent_mesh": validated.reparent_mesh, "mesh_to_body": mesh_to_body, "bounds": bounds,
|
||||||
|
"source_mesh": ClassDB.class_get_property(mesh, "mesh"), "source_bounds": mesh.get_aabb(), "source_transform": ClassDB.class_get_property(mesh, "global_transform"),
|
||||||
|
"body_transform": ClassDB.class_get_property(body, "global_transform"), "body_parent": body.get_parent(), "body_top_level": ClassDB.class_get_property(body, "top_level"),
|
||||||
|
"old_shape": ClassDB.class_get_property(collision, "shape"), "old_transform": ClassDB.class_get_property(collision, "transform"), "collision_top_level": ClassDB.class_get_property(collision, "top_level"),
|
||||||
|
"marker": mesh.get_meta(MARKER).duplicate(true), "body_marker": body.get_meta(MARKER).duplicate(true),
|
||||||
|
"collision_marker": collision.get_meta(MARKER).duplicate(true),
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
func _stale(plan: Dictionary, validated: Dictionary) -> String:
|
||||||
|
var mesh = plan.mesh
|
||||||
|
if not is_instance_valid(mesh) or not mesh.is_inside_tree():
|
||||||
|
return "mesh was removed"
|
||||||
|
if bool(plan.get("resource_changed", false)) or ClassDB.class_get_property(mesh, "mesh") != plan.source_mesh or not mesh.get_aabb().is_equal_approx(plan.source_bounds):
|
||||||
|
return "geometry changed"
|
||||||
|
if not bool(plan.get("refresh", false)):
|
||||||
|
if ClassDB.class_get_property(mesh, "owner") != plan.source_owner or not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.world_transform):
|
||||||
|
return "source owner or world transform changed"
|
||||||
|
if mesh.has_meta(MARKER) or mesh.get_parent().has_meta(MARKER):
|
||||||
|
return "mesh provenance changed"
|
||||||
|
return _host._plan_stale_reason(plan)
|
||||||
|
var body = plan.body
|
||||||
|
var collision = plan.collision
|
||||||
|
if not is_instance_valid(body) or not is_instance_valid(collision) or not body.is_inside_tree() or not collision.is_inside_tree():
|
||||||
|
return "body or collision was removed"
|
||||||
|
var linked := relationship(mesh, validated.scene_root, validated.body_type, validated.reparent_mesh)
|
||||||
|
if linked.is_empty() or linked.body != body or linked.collision != collision:
|
||||||
|
return "generated relationship changed"
|
||||||
|
if mesh.get_meta(MARKER) != plan.marker or body.get_meta(MARKER) != plan.body_marker or collision.get_meta(MARKER) != plan.collision_marker:
|
||||||
|
return "provenance changed"
|
||||||
|
if mesh.get_parent() != plan.parent or body.get_parent() != plan.body_parent or ClassDB.class_get_property(body, "top_level") != plan.body_top_level or ClassDB.class_get_property(collision, "top_level") != plan.collision_top_level:
|
||||||
|
return "topology changed"
|
||||||
|
if not ClassDB.class_get_property(mesh, "global_transform").is_equal_approx(plan.source_transform) or not ClassDB.class_get_property(body, "global_transform").is_equal_approx(plan.body_transform):
|
||||||
|
return "source or body transform changed"
|
||||||
|
if ClassDB.class_get_property(collision, "shape") != plan.old_shape or not ClassDB.class_get_property(collision, "transform").is_equal_approx(plan.old_transform):
|
||||||
|
return "collision was edited"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _prepare(plan: Dictionary, validated: Dictionary) -> Dictionary:
|
||||||
|
if not bool(plan.get("refresh", false)):
|
||||||
|
return _host._create_generated_entry(plan, plan.shape_type, validated.body_type, validated.reparent_mesh)
|
||||||
|
var shape: Shape3D
|
||||||
|
if plan.shape_type in ["convex", "trimesh"]:
|
||||||
|
var error: Dictionary = _host._validate_hull_workload(ClassDB.class_get_property(plan.mesh, "mesh"), plan.mesh_path, plan.shape_type)
|
||||||
|
if not error.is_empty():
|
||||||
|
return error
|
||||||
|
shape = _host._fit_mesh_shape(plan.mesh, plan.shape_type, plan.mesh_to_body)
|
||||||
|
if shape == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot fit degenerate mesh at %s" % plan.mesh_path)
|
||||||
|
else:
|
||||||
|
shape = ClassDB.instantiate(_host._SHAPE_3D_CLASSES[plan.shape_type])
|
||||||
|
_host._apply_shape_size(shape, plan.shape_type, {"aabb": plan.bounds}, true)
|
||||||
|
var entry := plan.duplicate()
|
||||||
|
entry["new_shape"] = shape
|
||||||
|
entry["new_transform"] = Transform3D.IDENTITY if plan.shape_type in ["convex", "trimesh"] else Transform3D(Basis.IDENTITY, _host._snap_tiny(plan.bounds.get_center()))
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
func cleanup(job: Dictionary) -> void:
|
||||||
|
_disconnect()
|
||||||
|
for entry in job.created:
|
||||||
|
if not bool(entry.get("refresh", false)) and is_instance_valid(entry.body):
|
||||||
|
entry.body.free()
|
||||||
|
job.created.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(job: Dictionary, error: Dictionary) -> bool:
|
||||||
|
cleanup(job)
|
||||||
|
job.result = error
|
||||||
|
job.phase = "done"
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func step(job: Dictionary, budget_usec: int) -> bool:
|
||||||
|
if _host == null:
|
||||||
|
_host = load("res://addons/godot_ai/handlers/physics_shape_handler.gd")
|
||||||
|
var v: Dictionary = job.validated
|
||||||
|
if job.connection != null and not _host._deferred_request_pending(job.connection, job.request_id):
|
||||||
|
return _fail(job, {})
|
||||||
|
if Time.get_ticks_msec() - int(job.started_ms) > _host._GENERATE_DEFERRED_TIMEOUT_MS:
|
||||||
|
return _fail(job, ErrorCodes.make(ErrorCodes.DEFERRED_TIMEOUT, "Physics shape refresh exceeded its time budget"))
|
||||||
|
if not is_instance_valid(v.scene_root) or EditorInterface.get_edited_scene_root() != v.scene_root:
|
||||||
|
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "Edited scene changed during physics shape refresh"))
|
||||||
|
var started := Time.get_ticks_usec()
|
||||||
|
var work := 0
|
||||||
|
while job.phase in ["plan", "prepare"]:
|
||||||
|
if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - started >= budget_usec:
|
||||||
|
return false
|
||||||
|
if job.phase == "plan":
|
||||||
|
if job.index >= v.paths.size():
|
||||||
|
job.phase = "prepare"
|
||||||
|
job.index = 0
|
||||||
|
continue
|
||||||
|
var planned := _plan(v.paths[job.index], v)
|
||||||
|
if planned.has("error"):
|
||||||
|
return _fail(job, planned)
|
||||||
|
var plan: Dictionary = planned.plan
|
||||||
|
var identity: int = plan.mesh.get_instance_id()
|
||||||
|
if _seen.has(identity):
|
||||||
|
return _fail(job, ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Multiple paths resolve to the same mesh"))
|
||||||
|
_seen[identity] = true
|
||||||
|
job.plans.append(plan)
|
||||||
|
_watch(plan.source_mesh, plan)
|
||||||
|
if bool(plan.get("refresh", false)):
|
||||||
|
_watch(plan.old_shape, plan)
|
||||||
|
else:
|
||||||
|
if job.index >= job.plans.size():
|
||||||
|
job.phase = "commit"
|
||||||
|
break
|
||||||
|
var plan: Dictionary = job.plans[job.index]
|
||||||
|
var reason := _stale(plan, v)
|
||||||
|
if not reason.is_empty():
|
||||||
|
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
|
||||||
|
var entry := _prepare(plan, v)
|
||||||
|
if entry.has("error"):
|
||||||
|
return _fail(job, entry)
|
||||||
|
job.created.append(entry)
|
||||||
|
job.index += 1
|
||||||
|
work += 1
|
||||||
|
for plan in job.plans:
|
||||||
|
var reason := _stale(plan, v)
|
||||||
|
if not reason.is_empty():
|
||||||
|
return _fail(job, ErrorCodes.make(ErrorCodes.EDITED_SCENE_MISMATCH, "%s: %s" % [plan.mesh_path, reason]))
|
||||||
|
_disconnect()
|
||||||
|
var entries: Array[Dictionary] = []
|
||||||
|
entries.assign(job.created)
|
||||||
|
_host._commit_generated_action(entries, v.scene_root, job.undo_redo, true)
|
||||||
|
job.committed = true
|
||||||
|
job.result = _host._generated_response(entries, v.scene_root, v.body_type)
|
||||||
|
job.phase = "done"
|
||||||
|
return true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b8xkp8fhvdf5k
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
||||||
|
|
||||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
const ResourceInspector := preload("res://addons/godot_ai/utils/resource_inspector.gd")
|
||||||
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
|
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
|
||||||
|
|
||||||
## Handles resource search, inspection, and assignment to nodes.
|
## Handles resource search, inspection, and assignment to nodes.
|
||||||
@@ -344,7 +345,7 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary, de
|
|||||||
var loaded := ResourceLoader.load(v)
|
var loaded := ResourceLoader.load(v)
|
||||||
if loaded == null:
|
if loaded == null:
|
||||||
return ErrorCodes.make(
|
return ErrorCodes.make(
|
||||||
ErrorCodes.INVALID_PARAMS,
|
ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
"Resource not found at path '%s' for property '%s'" % [v, key]
|
"Resource not found at path '%s' for property '%s'" % [v, key]
|
||||||
)
|
)
|
||||||
v = loaded
|
v = loaded
|
||||||
@@ -589,3 +590,35 @@ static func _custom_resource_info(type_str: String) -> Variant:
|
|||||||
"property_count": props.size(),
|
"property_count": props.size(),
|
||||||
}}
|
}}
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func inspect_resource(params: Dictionary) -> Dictionary:
|
||||||
|
for name in ["node_path", "property"]:
|
||||||
|
if not params.has(name):
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "inspect_resource requires %s" % name)
|
||||||
|
if params[name] is not String:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s must be a String" % name)
|
||||||
|
if params[name].is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "inspect_resource requires %s" % name)
|
||||||
|
var depth: Variant = params.get("depth", 2)
|
||||||
|
if typeof(depth) not in [TYPE_INT, TYPE_FLOAT]:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "depth must be an integer from 0 through 3")
|
||||||
|
if not is_finite(float(depth)) or float(depth) != floor(float(depth)) or depth < 0 or depth > 3:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "depth must be an integer from 0 through 3; received %s" % str(depth))
|
||||||
|
var resolved := McpNodeValidator.resolve_or_error(params.node_path, "node_path")
|
||||||
|
if resolved.has("error"):
|
||||||
|
return resolved
|
||||||
|
var node: Node = resolved.node
|
||||||
|
var native_resource_property := false
|
||||||
|
for property in ClassDB.class_get_property_list(node.get_class()):
|
||||||
|
if str(property.name) == params.property and int(property.type) == TYPE_OBJECT and int(property.hint) == PROPERTY_HINT_RESOURCE_TYPE:
|
||||||
|
native_resource_property = true
|
||||||
|
break
|
||||||
|
if not native_resource_property:
|
||||||
|
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' on %s is not a native Resource property; script and dynamic getters are not inspected" % [params.property, node.get_class()])
|
||||||
|
var resource: Variant = ClassDB.class_get_property(node, params.property)
|
||||||
|
if resource is not Resource:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Property '%s' on '%s' does not contain a Resource; assign one before inspecting" % [params.property, params.node_path])
|
||||||
|
if not ResourceInspector.supported(resource):
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource '%s' is scripted or outside the supported native shape, mesh, material, physics material, stylebox, gradient and curve families" % resource.get_class())
|
||||||
|
return ResourceInspector.new().inspect(resource, int(depth))
|
||||||
|
|||||||
@@ -6,6 +6,29 @@ const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_cap
|
|||||||
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
|
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
|
||||||
|
|
||||||
## Handles script creation, reading, attaching, detaching, and symbol inspection.
|
## Handles script creation, reading, attaching, detaching, and symbol inspection.
|
||||||
|
##
|
||||||
|
## Two script languages cross this surface (#908). GDScript (`.gd`) is the
|
||||||
|
## full contract: parse-validated on write, hot-reloaded when already loaded,
|
||||||
|
## outlined by `find_symbols`. C# (`.cs`) is text-only: the plugin writes the
|
||||||
|
## bytes and outlines the file, but it never compiles .NET — the caller has
|
||||||
|
## to build the project (editor Build button or `dotnet build`) to learn
|
||||||
|
## about compiler errors. Loading a `.cs` requires a .NET-enabled editor;
|
||||||
|
## resource recognition does not prove its managed class was built. Every
|
||||||
|
## `.cs` write says so
|
||||||
|
## (`diagnostics_status: "not_checked"`, `validation_hint`, `dotnet_editor`)
|
||||||
|
## instead of reporting a plain success the caller could mistake for
|
||||||
|
## validation.
|
||||||
|
|
||||||
|
const LANGUAGE_GDSCRIPT := "gdscript"
|
||||||
|
const LANGUAGE_CSHARP := "csharp"
|
||||||
|
const CSHARP_VALIDATION_HINT := (
|
||||||
|
"C# is written as text only; Godot AI does not compile .NET. Build the "
|
||||||
|
+ "project (editor Build button or `dotnet build`) to surface compiler "
|
||||||
|
+ "errors, then filesystem_manage(op=\"scan\") before attaching the script."
|
||||||
|
)
|
||||||
|
const UNSUPPORTED_EXTENSION_MESSAGE := (
|
||||||
|
"Path must end with .gd or .cs (use filesystem_manage op=\"write_text\" for other text files)"
|
||||||
|
)
|
||||||
|
|
||||||
var _undo_redo: EditorUndoRedoManager
|
var _undo_redo: EditorUndoRedoManager
|
||||||
var _connection: McpConnection
|
var _connection: McpConnection
|
||||||
@@ -27,8 +50,11 @@ func create_script(params: Dictionary) -> Dictionary:
|
|||||||
if path_err != null:
|
if path_err != null:
|
||||||
return path_err
|
return path_err
|
||||||
|
|
||||||
if not path.ends_with(".gd"):
|
var language := script_language(path)
|
||||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd")
|
if language.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, UNSUPPORTED_EXTENSION_MESSAGE)
|
||||||
|
var is_csharp := language == LANGUAGE_CSHARP
|
||||||
|
var can_settle := not is_csharp or editor_has_dotnet()
|
||||||
|
|
||||||
var existed_before := FileAccess.file_exists(path)
|
var existed_before := FileAccess.file_exists(path)
|
||||||
|
|
||||||
@@ -42,12 +68,16 @@ func create_script(params: Dictionary) -> Dictionary:
|
|||||||
"path": path,
|
"path": path,
|
||||||
"size": content.length(),
|
"size": content.length(),
|
||||||
"committed": true,
|
"committed": true,
|
||||||
"import_settled": existed_before,
|
"import_settled": existed_before and can_settle,
|
||||||
"import_settle": "already_known" if existed_before else "not_waited",
|
"import_settle": "already_known" if existed_before and can_settle else "not_waited",
|
||||||
"undoable": false,
|
"undoable": false,
|
||||||
"reason": "File system operations cannot be undone via editor undo",
|
"reason": "File system operations cannot be undone via editor undo",
|
||||||
|
"language": language,
|
||||||
}
|
}
|
||||||
_attach_gdscript_diagnostics(data, path, content)
|
if is_csharp:
|
||||||
|
_attach_csharp_not_checked(data)
|
||||||
|
else:
|
||||||
|
_attach_gdscript_diagnostics(data, path, content)
|
||||||
|
|
||||||
# A freshly-declared `class_name` is NOT in the global class table until a
|
# A freshly-declared `class_name` is NOT in the global class table until a
|
||||||
# filesystem scan runs — update_file() below registers the file with the
|
# filesystem scan runs — update_file() below registers the file with the
|
||||||
@@ -60,7 +90,7 @@ func create_script(params: Dictionary) -> Dictionary:
|
|||||||
# Skip the hint when the script failed to parse: a scan won't register a
|
# Skip the hint when the script failed to parse: a scan won't register a
|
||||||
# class from a broken script, so pointing at op="scan" would steer the caller
|
# class from a broken script, so pointing at op="scan" would steer the caller
|
||||||
# away from the real fix (the parse error already attached above).
|
# away from the real fix (the parse error already attached above).
|
||||||
var declared_class := _extract_class_name(content)
|
var declared_class := "" if is_csharp else _extract_class_name(content)
|
||||||
if (
|
if (
|
||||||
not declared_class.is_empty()
|
not declared_class.is_empty()
|
||||||
and not _script_has_error_diagnostics(data)
|
and not _script_has_error_diagnostics(data)
|
||||||
@@ -83,21 +113,27 @@ func create_script(params: Dictionary) -> Dictionary:
|
|||||||
# BEFORE registering the file: a GUI editor loads the script inside
|
# BEFORE registering the file: a GUI editor loads the script inside
|
||||||
# update_file() (see _refresh_loaded_gdscript for the ordering contract).
|
# update_file() (see _refresh_loaded_gdscript for the ordering contract).
|
||||||
if existed_before:
|
if existed_before:
|
||||||
_refresh_loaded_gdscript(data, path, content)
|
if is_csharp:
|
||||||
|
_mark_csharp_not_reloaded(data)
|
||||||
|
else:
|
||||||
|
_refresh_loaded_gdscript(data, path, content)
|
||||||
|
|
||||||
_register_written_file(path)
|
_register_written_file(path)
|
||||||
|
|
||||||
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
|
# `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
|
||||||
# can rm the full set in one go.
|
# can rm the full set in one go. A `.cs` only gets a sidecar on a .NET
|
||||||
McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"])
|
# editor build, where the C# loader registers it as a resource.
|
||||||
|
McpResourceIO.attach_cleanup_hint(data, existed_before, _cleanup_paths(path, is_csharp))
|
||||||
|
|
||||||
# scan() is async — ResourceLoader.exists(path) returns false until Godot's
|
# scan() is async — ResourceLoader.exists(path) returns false until Godot's
|
||||||
# filesystem pipeline finishes. If we reply now, an immediate attach_script
|
# filesystem pipeline finishes. If we reply now, an immediate attach_script
|
||||||
# races and 404s (#261). Defer the response until the resource is visible
|
# races and 404s (#261). Defer the response until the resource is visible
|
||||||
# (or a bounded timeout elapses). For freshly-created files we wait; on
|
# (or a bounded timeout elapses). For freshly-created files we wait; on
|
||||||
# overwrite the resource was already known to ResourceLoader, so reply now.
|
# overwrite the resource was already known to ResourceLoader, so reply now.
|
||||||
|
# A `.cs` on a non-.NET editor never becomes a resource at all — waiting
|
||||||
|
# would burn the whole settle window for nothing, so reply synchronously.
|
||||||
var request_id: String = params.get("_request_id", "")
|
var request_id: String = params.get("_request_id", "")
|
||||||
if not existed_before and _connection != null and not request_id.is_empty():
|
if can_settle and not existed_before and _connection != null and not request_id.is_empty():
|
||||||
McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
|
McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
|
||||||
return McpDispatcher.DEFERRED_RESPONSE
|
return McpDispatcher.DEFERRED_RESPONSE
|
||||||
|
|
||||||
@@ -106,6 +142,51 @@ func create_script(params: Dictionary) -> Dictionary:
|
|||||||
return {"data": data}
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
## Which authoring contract a script path falls under: LANGUAGE_GDSCRIPT,
|
||||||
|
## LANGUAGE_CSHARP, or "" for anything the script tools refuse.
|
||||||
|
static func script_language(path: String) -> String:
|
||||||
|
if path.ends_with(".gd"):
|
||||||
|
return LANGUAGE_GDSCRIPT
|
||||||
|
if path.ends_with(".cs"):
|
||||||
|
return LANGUAGE_CSHARP
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## True on a .NET-enabled editor build. `CSharpScript` is registered with
|
||||||
|
## ClassDB only when the mono module is compiled in, which is exactly when
|
||||||
|
## ResourceLoader can load a `.cs` (and when Godot writes its `.cs.uid`).
|
||||||
|
## A string lookup, so it parses on every supported engine.
|
||||||
|
static func editor_has_dotnet() -> bool:
|
||||||
|
return ClassDB.class_exists("CSharpScript")
|
||||||
|
|
||||||
|
|
||||||
|
## The `.cs` counterpart of `_attach_gdscript_diagnostics`: same response
|
||||||
|
## keys so a caller reads one shape, but `diagnostics_status` says the file
|
||||||
|
## was NOT validated. An empty `diagnostics` array on its own would read as
|
||||||
|
## "checked, clean", which is the trap #908 is about.
|
||||||
|
static func _attach_csharp_not_checked(data: Dictionary) -> void:
|
||||||
|
data["diagnostics"] = []
|
||||||
|
data["diagnostics_detail"] = "none"
|
||||||
|
data["diagnostics_scope"] = "this_file"
|
||||||
|
data["diagnostics_status"] = "not_checked"
|
||||||
|
data["validation_hint"] = CSHARP_VALIDATION_HINT
|
||||||
|
data["dotnet_editor"] = editor_has_dotnet()
|
||||||
|
|
||||||
|
|
||||||
|
## The `.cs` counterpart of `_refresh_loaded_gdscript`: C# never hot-reloads
|
||||||
|
## from source — the assembly has to be rebuilt — so "the bytes changed" is
|
||||||
|
## all a write can promise.
|
||||||
|
static func _mark_csharp_not_reloaded(data: Dictionary) -> void:
|
||||||
|
data["reloaded"] = false
|
||||||
|
data["reload_reason"] = "csharp_requires_build"
|
||||||
|
|
||||||
|
|
||||||
|
static func _cleanup_paths(path: String, is_csharp: bool) -> Array:
|
||||||
|
if is_csharp and not editor_has_dotnet():
|
||||||
|
return [path]
|
||||||
|
return [path, path + ".uid"]
|
||||||
|
|
||||||
|
|
||||||
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
|
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
|
||||||
## (no full parse) for create_script's "scan_required" hint. Stops at the first
|
## (no full parse) for create_script's "scan_required" hint. Stops at the first
|
||||||
## space/tab or comma so all three valid forms yield just the name:
|
## space/tab or comma so all three valid forms yield just the name:
|
||||||
@@ -340,8 +421,10 @@ func patch_script(params: Dictionary) -> Dictionary:
|
|||||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
|
||||||
if not "new_text" in params:
|
if not "new_text" in params:
|
||||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
|
||||||
if not path.ends_with(".gd"):
|
var language := script_language(path)
|
||||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)")
|
if language.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, UNSUPPORTED_EXTENSION_MESSAGE)
|
||||||
|
var is_csharp := language == LANGUAGE_CSHARP
|
||||||
if old_text.is_empty():
|
if old_text.is_empty():
|
||||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
|
||||||
|
|
||||||
@@ -384,14 +467,18 @@ func patch_script(params: Dictionary) -> Dictionary:
|
|||||||
"old_size": content.length(),
|
"old_size": content.length(),
|
||||||
"undoable": false,
|
"undoable": false,
|
||||||
"reason": "File system operations cannot be undone via editor undo",
|
"reason": "File system operations cannot be undone via editor undo",
|
||||||
|
"language": language,
|
||||||
}
|
}
|
||||||
_attach_gdscript_diagnostics(data, path, new_content)
|
if is_csharp:
|
||||||
|
_attach_csharp_not_checked(data)
|
||||||
# The file is fresh but any already-loaded GDScript for it is not (#937);
|
_mark_csharp_not_reloaded(data)
|
||||||
# make "patch succeeded" mean the loaded code changed, not just the bytes.
|
else:
|
||||||
# Decide and refresh before registering the file with the editor — a GUI
|
_attach_gdscript_diagnostics(data, path, new_content)
|
||||||
# editor loads the script inside update_file() (see _refresh_loaded_gdscript).
|
# The file is fresh but any already-loaded GDScript for it is not (#937);
|
||||||
_refresh_loaded_gdscript(data, path, new_content)
|
# make "patch succeeded" mean the loaded code changed, not just the bytes.
|
||||||
|
# Decide and refresh before registering the file with the editor — a GUI
|
||||||
|
# editor loads the script inside update_file() (see _refresh_loaded_gdscript).
|
||||||
|
_refresh_loaded_gdscript(data, path, new_content)
|
||||||
_register_written_file(path)
|
_register_written_file(path)
|
||||||
|
|
||||||
return {"data": data}
|
return {"data": data}
|
||||||
@@ -411,6 +498,17 @@ func attach_script(params: Dictionary) -> Dictionary:
|
|||||||
if spath_err != null:
|
if spath_err != null:
|
||||||
return spath_err
|
return spath_err
|
||||||
|
|
||||||
|
# A `.cs` is a Script only where the mono module exists. Without it the
|
||||||
|
# generic "Script not found" below would send the caller hunting for a
|
||||||
|
# path problem that isn't one (#908).
|
||||||
|
var is_csharp := script_language(script_path) == LANGUAGE_CSHARP
|
||||||
|
if is_csharp and not editor_has_dotnet():
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"script_path: %s is a C# script but this editor build has no .NET support " % script_path
|
||||||
|
+ "(CSharpScript is unavailable). Run a .NET-enabled Godot build, or attach a .gd script.",
|
||||||
|
)
|
||||||
|
|
||||||
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
|
var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
|
||||||
if _resolved.has("error"):
|
if _resolved.has("error"):
|
||||||
return _resolved
|
return _resolved
|
||||||
@@ -418,11 +516,17 @@ func attach_script(params: Dictionary) -> Dictionary:
|
|||||||
var _scene_root: Node = _resolved.scene_root
|
var _scene_root: Node = _resolved.scene_root
|
||||||
|
|
||||||
if not ResourceLoader.exists(script_path):
|
if not ResourceLoader.exists(script_path):
|
||||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path)
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
|
"Script not found: %s" % script_path + _csharp_load_hint(is_csharp),
|
||||||
|
)
|
||||||
|
|
||||||
var script: Script = load(script_path)
|
var script: Script = load(script_path)
|
||||||
if script == null:
|
if script == null:
|
||||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path)
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
"Failed to load script: %s" % script_path + _csharp_load_hint(is_csharp),
|
||||||
|
)
|
||||||
|
|
||||||
var old_script: Script = node.get_script()
|
var old_script: Script = node.get_script()
|
||||||
|
|
||||||
@@ -441,6 +545,19 @@ func attach_script(params: Dictionary) -> Dictionary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Appended to attach_script's load failures for a `.cs`: on a .NET editor
|
||||||
|
## the usual reason is that the assembly hasn't been built since the file
|
||||||
|
## appeared, not that the path is wrong.
|
||||||
|
static func _csharp_load_hint(is_csharp: bool) -> String:
|
||||||
|
if not is_csharp:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
" (check the C# source path and build the project assembly using the "
|
||||||
|
+ "editor Build button or `dotnet build`, then filesystem_manage(op=\"scan\"); "
|
||||||
|
+ "resource recognition alone does not verify a compiled class)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func detach_script(params: Dictionary) -> Dictionary:
|
func detach_script(params: Dictionary) -> Dictionary:
|
||||||
var node_path: String = params.get("path", "")
|
var node_path: String = params.get("path", "")
|
||||||
|
|
||||||
@@ -478,6 +595,10 @@ func find_symbols(params: Dictionary) -> Dictionary:
|
|||||||
if path_err != null:
|
if path_err != null:
|
||||||
return path_err
|
return path_err
|
||||||
|
|
||||||
|
var language := script_language(path)
|
||||||
|
if language.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Cannot outline %s: path must end with .gd or .cs (use filesystem_manage op=\"read_text\" for other text files)" % path)
|
||||||
|
|
||||||
if not FileAccess.file_exists(path):
|
if not FileAccess.file_exists(path):
|
||||||
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
|
||||||
|
|
||||||
@@ -488,6 +609,9 @@ func find_symbols(params: Dictionary) -> Dictionary:
|
|||||||
var content := file.get_as_text()
|
var content := file.get_as_text()
|
||||||
file.close()
|
file.close()
|
||||||
|
|
||||||
|
if language == LANGUAGE_CSHARP:
|
||||||
|
return {"data": _csharp_symbols(path, content)}
|
||||||
|
|
||||||
var functions: Array[Dictionary] = []
|
var functions: Array[Dictionary] = []
|
||||||
var signals_list: Array[String] = []
|
var signals_list: Array[String] = []
|
||||||
var exports: Array[Dictionary] = []
|
var exports: Array[Dictionary] = []
|
||||||
@@ -560,6 +684,7 @@ func find_symbols(params: Dictionary) -> Dictionary:
|
|||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"path": path,
|
"path": path,
|
||||||
|
"language": LANGUAGE_GDSCRIPT,
|
||||||
"class_name": class_name_str,
|
"class_name": class_name_str,
|
||||||
"extends": extends_str,
|
"extends": extends_str,
|
||||||
"functions": functions,
|
"functions": functions,
|
||||||
@@ -570,3 +695,109 @@ func find_symbols(params: Dictionary) -> Dictionary:
|
|||||||
"export_count": exports.size(),
|
"export_count": exports.size(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ----- C# outline ------------------------------------------------------------
|
||||||
|
|
||||||
|
## Lines that start with one of these can still match the method pattern
|
||||||
|
## (`else if (`, `return Foo(`, `new Bar(`), so they're skipped by first word.
|
||||||
|
const _CSHARP_CONTROL_WORDS: PackedStringArray = [
|
||||||
|
"if", "else", "for", "foreach", "while", "do", "switch", "case", "catch",
|
||||||
|
"using", "return", "new", "throw", "await", "lock", "yield", "goto",
|
||||||
|
]
|
||||||
|
|
||||||
|
## Same outline shape as the GDScript branch, from a line-based scan (no
|
||||||
|
## Roslyn here). Mirrors Godot's C# conventions: the first `class` is the
|
||||||
|
## script class and its first base type is `extends`; `[Signal]` marks a
|
||||||
|
## delegate whose Godot signal name drops the `EventHandler` suffix;
|
||||||
|
## `[Export]` marks the next field or property. Attributes may sit on their
|
||||||
|
## own line or share the member's line, and may stack (`[Export] [Obsolete]`).
|
||||||
|
static func _csharp_symbols(path: String, content: String) -> Dictionary:
|
||||||
|
var functions: Array[Dictionary] = []
|
||||||
|
var signals_list: Array[String] = []
|
||||||
|
var exports: Array[Dictionary] = []
|
||||||
|
var class_name_str := ""
|
||||||
|
var extends_str := ""
|
||||||
|
|
||||||
|
var class_re := RegEx.create_from_string(
|
||||||
|
"^(?:(?:public|internal|private|protected|static|partial|abstract|sealed)\\s+)*"
|
||||||
|
+ "class\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*<[^>]*>)?(?:\\s*:\\s*([A-Za-z_][A-Za-z0-9_.]*))?"
|
||||||
|
)
|
||||||
|
var method_re := RegEx.create_from_string(
|
||||||
|
"^(?:(?:public|private|protected|internal|static|override|virtual|abstract|async|sealed|new|partial|extern|unsafe)\\s+)*"
|
||||||
|
+ "[A-Za-z_][A-Za-z0-9_<>\\[\\],.?]*\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?:<[^>]*>)?\\s*\\("
|
||||||
|
)
|
||||||
|
var delegate_re := RegEx.create_from_string("delegate\\s+\\S+\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(")
|
||||||
|
var member_re := RegEx.create_from_string(
|
||||||
|
"^(?:(?:public|private|protected|internal|static|readonly|new|virtual|override|required)\\s+)*"
|
||||||
|
+ "[A-Za-z_][A-Za-z0-9_<>\\[\\],.?]*\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?:\\{|=|;|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pending_signal := false
|
||||||
|
var pending_export := false
|
||||||
|
var lines := content.split("\n")
|
||||||
|
for i in lines.size():
|
||||||
|
var line := lines[i].strip_edges()
|
||||||
|
if line.is_empty() or line.begins_with("//") or line.begins_with("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Peel leading attributes; what's left (if anything) is the member.
|
||||||
|
var rest := line
|
||||||
|
while rest.begins_with("["):
|
||||||
|
var close := rest.find("]")
|
||||||
|
if close < 0:
|
||||||
|
break
|
||||||
|
var attr := rest.substr(1, close - 1).strip_edges()
|
||||||
|
if attr == "Signal" or attr.begins_with("Signal("):
|
||||||
|
pending_signal = true
|
||||||
|
if attr == "Export" or attr.begins_with("Export("):
|
||||||
|
pending_export = true
|
||||||
|
rest = rest.substr(close + 1).strip_edges()
|
||||||
|
if rest.is_empty():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if class_name_str.is_empty():
|
||||||
|
var cm := class_re.search(rest)
|
||||||
|
if cm != null:
|
||||||
|
class_name_str = cm.get_string(1)
|
||||||
|
extends_str = cm.get_string(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pending_signal:
|
||||||
|
pending_signal = false
|
||||||
|
var dm := delegate_re.search(rest)
|
||||||
|
if dm != null:
|
||||||
|
var sig := dm.get_string(1)
|
||||||
|
if sig.ends_with("EventHandler"):
|
||||||
|
sig = sig.substr(0, sig.length() - "EventHandler".length())
|
||||||
|
signals_list.append(sig)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pending_export:
|
||||||
|
pending_export = false
|
||||||
|
var em := member_re.search(rest)
|
||||||
|
if em != null:
|
||||||
|
exports.append({"name": em.get_string(1), "line": i + 1})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rest.find("delegate ") >= 0:
|
||||||
|
continue
|
||||||
|
var first_word := rest.get_slice(" ", 0).get_slice("(", 0)
|
||||||
|
if first_word in _CSHARP_CONTROL_WORDS:
|
||||||
|
continue
|
||||||
|
var mm := method_re.search(rest)
|
||||||
|
if mm != null:
|
||||||
|
functions.append({"name": mm.get_string(1), "line": i + 1})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"language": LANGUAGE_CSHARP,
|
||||||
|
"class_name": class_name_str,
|
||||||
|
"extends": extends_str,
|
||||||
|
"functions": functions,
|
||||||
|
"signals": signals_list,
|
||||||
|
"exports": exports,
|
||||||
|
"function_count": functions.size(),
|
||||||
|
"signal_count": signals_list.size(),
|
||||||
|
"export_count": exports.size(),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
@tool
|
||||||
|
extends "res://addons/godot_ai/handlers/command_handler.gd"
|
||||||
|
|
||||||
|
## Raw shader authoring: create/read/validate/patch .gdshader and .gdshaderinc
|
||||||
|
## files. Every write parses the staged code through the engine's own shader
|
||||||
|
## compiler BEFORE the destination is touched, so invalid shader code never
|
||||||
|
## reaches disk and a failed validation preserves an existing file.
|
||||||
|
##
|
||||||
|
## Godot exposes no structured compile-error API to GDScript (the compiler's
|
||||||
|
## line-tagged output only reaches the editor Output panel), so validity is
|
||||||
|
## detected through a sentinel uniform: `Shader.get_shader_uniform_list()` is
|
||||||
|
## populated only when the whole translation unit parses, so a scratch copy
|
||||||
|
## with a generated uniform declaration appended reports parse success by
|
||||||
|
## containing that uniform. Failures return one synthesized diagnostic; the
|
||||||
|
## editor Output panel keeps the engine's line-tagged details.
|
||||||
|
|
||||||
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
|
||||||
|
|
||||||
|
const SHADER_EXT := "gdshader"
|
||||||
|
const INCLUDE_EXT := "gdshaderinc"
|
||||||
|
## Bounded request size. A shader is hand-authored text; 256 KiB is far past
|
||||||
|
## any real shader and keeps a malformed request from parking the editor.
|
||||||
|
const MAX_CODE_BYTES := 262144
|
||||||
|
const SHADER_TYPES := ["spatial", "canvas_item", "particles", "sky", "fog"]
|
||||||
|
const MODE_NAMES := {
|
||||||
|
Shader.MODE_SPATIAL: "spatial",
|
||||||
|
Shader.MODE_CANVAS_ITEM: "canvas_item",
|
||||||
|
Shader.MODE_PARTICLES: "particles",
|
||||||
|
Shader.MODE_SKY: "sky",
|
||||||
|
Shader.MODE_FOG: "fog",
|
||||||
|
}
|
||||||
|
const PARSE_FAILURE_TEXT := (
|
||||||
|
"Shader failed the parse/type pass. The editor Output panel carries Godot's "
|
||||||
|
+ "line-tagged compiler details; check syntax, declared uniforms, and "
|
||||||
|
+ "stage-specific builtins for the shader type."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func create_shader(params: Dictionary) -> Dictionary:
|
||||||
|
var resource_path: String = params.get("resource_path", "")
|
||||||
|
var code: String = params.get("code", "")
|
||||||
|
var overwrite: bool = params.get("overwrite", false)
|
||||||
|
var shader_type: String = params.get("shader_type", "spatial")
|
||||||
|
|
||||||
|
var path_err = McpPathValidator.path_error(resource_path, "resource_path", true)
|
||||||
|
if path_err != null:
|
||||||
|
return path_err
|
||||||
|
var kind := _kind_for_path(resource_path)
|
||||||
|
if kind.is_empty():
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"resource_path must end in .gdshader or .gdshaderinc: %s" % resource_path
|
||||||
|
)
|
||||||
|
if not params.has("code") or code.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: code")
|
||||||
|
var size_err := _size_error(code)
|
||||||
|
if size_err != null:
|
||||||
|
return size_err
|
||||||
|
var type_err := _shader_type_error(shader_type)
|
||||||
|
if type_err != null:
|
||||||
|
return type_err
|
||||||
|
|
||||||
|
var directory := resource_path.get_base_dir()
|
||||||
|
if not DirAccess.dir_exists_absolute(directory):
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
|
"Destination directory does not exist: %s" % directory
|
||||||
|
)
|
||||||
|
var existed_before := FileAccess.file_exists(resource_path)
|
||||||
|
if existed_before and not overwrite:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.INVALID_PARAMS,
|
||||||
|
"Shader already exists at %s (pass overwrite=true to replace)" % resource_path
|
||||||
|
)
|
||||||
|
|
||||||
|
var validation := _validate_code(code, kind, directory, shader_type)
|
||||||
|
if validation.has("error"):
|
||||||
|
return validation.error
|
||||||
|
if not validation.valid:
|
||||||
|
return _invalid_with_diagnostics(
|
||||||
|
"Shader parse failed; %s was not written" % resource_path, validation
|
||||||
|
)
|
||||||
|
var write_err := _write_atomic(resource_path, code, kind)
|
||||||
|
if write_err != null:
|
||||||
|
return write_err
|
||||||
|
|
||||||
|
var efs := EditorInterface.get_resource_filesystem()
|
||||||
|
if efs != null:
|
||||||
|
efs.update_file(resource_path)
|
||||||
|
_refresh_cached_resource(resource_path, code, kind)
|
||||||
|
|
||||||
|
var uniforms: Array = validation.uniforms
|
||||||
|
var data := {
|
||||||
|
"path": resource_path,
|
||||||
|
"kind": kind,
|
||||||
|
"shader_type": validation.shader_type,
|
||||||
|
"uniforms": uniforms,
|
||||||
|
"uniform_count": uniforms.size(),
|
||||||
|
"line_count": _line_count(code),
|
||||||
|
"size": code.length(),
|
||||||
|
"overwritten": existed_before,
|
||||||
|
"diagnostics": validation.diagnostics,
|
||||||
|
"undoable": false,
|
||||||
|
"reason": "File creation is persistent; delete the file manually to revert",
|
||||||
|
}
|
||||||
|
McpResourceIO.attach_cleanup_hint(data, existed_before, [resource_path, resource_path + ".uid"])
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
func get_shader(params: Dictionary) -> Dictionary:
|
||||||
|
var path: String = params.get("path", "")
|
||||||
|
var path_err = McpPathValidator.path_error(path, "path")
|
||||||
|
if path_err != null:
|
||||||
|
return path_err
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % path)
|
||||||
|
var kind := _kind_for_path(path)
|
||||||
|
if kind.is_empty():
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.WRONG_TYPE,
|
||||||
|
"Not a .gdshader or .gdshaderinc file: %s" % path
|
||||||
|
)
|
||||||
|
|
||||||
|
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
||||||
|
if kind == "include":
|
||||||
|
if not (loaded is ShaderInclude):
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.WRONG_TYPE, "Resource at %s is not a ShaderInclude" % path
|
||||||
|
)
|
||||||
|
var code: String = (loaded as ShaderInclude).get_code()
|
||||||
|
return {"data": {
|
||||||
|
"path": path,
|
||||||
|
"kind": kind,
|
||||||
|
"resource_class": "ShaderInclude",
|
||||||
|
"code": code,
|
||||||
|
"line_count": _line_count(code),
|
||||||
|
"size": code.length(),
|
||||||
|
"includes": _parse_includes(code),
|
||||||
|
}}
|
||||||
|
if not (loaded is Shader):
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % path)
|
||||||
|
var shader := loaded as Shader
|
||||||
|
var shader_code: String = shader.get_code()
|
||||||
|
var uniforms := _serialize_uniforms(shader)
|
||||||
|
return {"data": {
|
||||||
|
"path": path,
|
||||||
|
"kind": kind,
|
||||||
|
"resource_class": "Shader",
|
||||||
|
"code": shader_code,
|
||||||
|
"line_count": _line_count(shader_code),
|
||||||
|
"size": shader_code.length(),
|
||||||
|
"shader_type": MODE_NAMES.get(shader.get_mode(), ""),
|
||||||
|
"uniforms": uniforms,
|
||||||
|
"uniform_count": uniforms.size(),
|
||||||
|
"render_modes": _parse_render_modes(shader_code),
|
||||||
|
"includes": _parse_includes(shader_code),
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
func validate_shader(params: Dictionary) -> Dictionary:
|
||||||
|
var code: String = params.get("code", "")
|
||||||
|
var kind: String = params.get("kind", "shader")
|
||||||
|
var shader_type: String = params.get("shader_type", "spatial")
|
||||||
|
var base_dir: String = params.get("base_dir", "")
|
||||||
|
|
||||||
|
if not params.has("code") or code.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: code")
|
||||||
|
if kind != "shader" and kind != "include":
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE, "kind must be 'shader' or 'include'"
|
||||||
|
)
|
||||||
|
var size_err := _size_error(code)
|
||||||
|
if size_err != null:
|
||||||
|
return size_err
|
||||||
|
var type_err := _shader_type_error(shader_type)
|
||||||
|
if type_err != null:
|
||||||
|
return type_err
|
||||||
|
## Standalone validation defaults to user:// scratch space. Pass base_dir to
|
||||||
|
## validate relative #include resolution against the directory the shader
|
||||||
|
## will live in.
|
||||||
|
if not base_dir.is_empty():
|
||||||
|
var base_err = McpPathValidator.path_error(base_dir, "base_dir")
|
||||||
|
if base_err != null:
|
||||||
|
return base_err
|
||||||
|
if not DirAccess.dir_exists_absolute(base_dir):
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.RESOURCE_NOT_FOUND, "base_dir does not exist: %s" % base_dir
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
base_dir = "user://"
|
||||||
|
|
||||||
|
var validation := _validate_code(code, kind, base_dir, shader_type)
|
||||||
|
if validation.has("error"):
|
||||||
|
return validation.error
|
||||||
|
return {"data": {
|
||||||
|
"valid": validation.valid,
|
||||||
|
"kind": kind,
|
||||||
|
"shader_type": validation.shader_type,
|
||||||
|
"uniforms": validation.uniforms,
|
||||||
|
"uniform_count": validation.uniforms.size(),
|
||||||
|
"diagnostics": validation.diagnostics,
|
||||||
|
"errors": validation.errors,
|
||||||
|
"warnings": validation.warnings,
|
||||||
|
"undoable": false,
|
||||||
|
"reason": "Validation parses a scratch copy; no project state changed",
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
func patch_shader(params: Dictionary) -> Dictionary:
|
||||||
|
var path: String = params.get("path", "")
|
||||||
|
var old_text: String = params.get("old_text", "")
|
||||||
|
var new_text: String = params.get("new_text", "")
|
||||||
|
var replace_all: bool = params.get("replace_all", false)
|
||||||
|
var shader_type: String = params.get("shader_type", "spatial")
|
||||||
|
|
||||||
|
var path_err = McpPathValidator.path_error(path, "path", true)
|
||||||
|
if path_err != null:
|
||||||
|
return path_err
|
||||||
|
if not params.has("old_text"):
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
|
||||||
|
if not params.has("new_text"):
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
|
||||||
|
var kind := _kind_for_path(path)
|
||||||
|
if kind.is_empty():
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gdshader or .gdshaderinc: %s" % path
|
||||||
|
)
|
||||||
|
if old_text.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
|
||||||
|
var type_err := _shader_type_error(shader_type)
|
||||||
|
if type_err != null:
|
||||||
|
return type_err
|
||||||
|
|
||||||
|
var read := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if read == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path)
|
||||||
|
var content := read.get_as_text()
|
||||||
|
read.close()
|
||||||
|
|
||||||
|
var match_count := content.count(old_text)
|
||||||
|
if match_count == 0:
|
||||||
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path)
|
||||||
|
if match_count > 1 and not replace_all:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.INVALID_PARAMS,
|
||||||
|
"old_text matches %d times; pass replace_all=true or provide a more specific snippet"
|
||||||
|
% match_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
var new_content: String
|
||||||
|
var replacements: int
|
||||||
|
if replace_all:
|
||||||
|
new_content = content.replace(old_text, new_text)
|
||||||
|
replacements = match_count
|
||||||
|
else:
|
||||||
|
var idx := content.find(old_text)
|
||||||
|
new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length())
|
||||||
|
replacements = 1
|
||||||
|
var size_err := _size_error(new_content)
|
||||||
|
if size_err != null:
|
||||||
|
return size_err
|
||||||
|
|
||||||
|
var validation := _validate_code(new_content, kind, path.get_base_dir(), shader_type)
|
||||||
|
if validation.has("error"):
|
||||||
|
return validation.error
|
||||||
|
if not validation.valid:
|
||||||
|
return _invalid_with_diagnostics(
|
||||||
|
"Shader parse failed; %s was not modified" % path, validation
|
||||||
|
)
|
||||||
|
var write_err := _write_atomic(path, new_content, kind)
|
||||||
|
if write_err != null:
|
||||||
|
return write_err
|
||||||
|
|
||||||
|
var efs := EditorInterface.get_resource_filesystem()
|
||||||
|
if efs != null:
|
||||||
|
efs.update_file(path)
|
||||||
|
_refresh_cached_resource(path, new_content, kind)
|
||||||
|
|
||||||
|
var uniforms: Array = validation.uniforms
|
||||||
|
return {"data": {
|
||||||
|
"path": path,
|
||||||
|
"kind": kind,
|
||||||
|
"replacements": replacements,
|
||||||
|
"size": new_content.length(),
|
||||||
|
"old_size": content.length(),
|
||||||
|
"line_count": _line_count(new_content),
|
||||||
|
"shader_type": validation.shader_type,
|
||||||
|
"uniforms": uniforms,
|
||||||
|
"uniform_count": uniforms.size(),
|
||||||
|
"diagnostics": validation.diagnostics,
|
||||||
|
"undoable": false,
|
||||||
|
"reason": "File system operations cannot be undone via editor undo",
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
## Parse `code` through the engine's shader parser and report validity,
|
||||||
|
## uniforms, and a synthesized diagnostic on failure. `directory` receives the
|
||||||
|
## scratch files (destination directory for writes, `user://` for standalone
|
||||||
|
## validation). The scratch `.gdshader` is written beside the destination and
|
||||||
|
## loaded with `ResourceLoader`, so relative `#include` paths resolve exactly
|
||||||
|
## as they will for the saved file. `.gdshaderinc` cannot compile alone, so it
|
||||||
|
## is wrapped in a minimal `shader_type <type>; #include "<scratch>"` shader in
|
||||||
|
## the same directory (include resolution is relative to the including file).
|
||||||
|
static func _validate_code(
|
||||||
|
code: String, kind: String, directory: String, shader_type: String
|
||||||
|
) -> Dictionary:
|
||||||
|
var token := "%d-%d" % [OS.get_process_id(), Time.get_ticks_usec()]
|
||||||
|
var sentinel := "_mcp_validate_%d" % Time.get_ticks_usec()
|
||||||
|
var ext := INCLUDE_EXT if kind == "include" else SHADER_EXT
|
||||||
|
var scratch_path := _scratch_path(directory, token, ext)
|
||||||
|
var staged := code if kind == "include" else code + "\nuniform float %s;\n" % sentinel
|
||||||
|
var write_err = _write_scratch(scratch_path, staged)
|
||||||
|
if write_err != null:
|
||||||
|
return {"error": write_err}
|
||||||
|
var scratch_paths: Array[String] = [scratch_path]
|
||||||
|
var capture_path := scratch_path
|
||||||
|
if kind == "include":
|
||||||
|
var wrapper_path := _scratch_path(directory, token, SHADER_EXT)
|
||||||
|
var wrapper := "shader_type %s;\n#include \"%s\"\nuniform float %s;\n" % [
|
||||||
|
shader_type, scratch_path.get_file(), sentinel
|
||||||
|
]
|
||||||
|
write_err = _write_scratch(wrapper_path, wrapper)
|
||||||
|
if write_err != null:
|
||||||
|
_remove_paths(scratch_paths)
|
||||||
|
return {"error": write_err}
|
||||||
|
scratch_paths.append(wrapper_path)
|
||||||
|
capture_path = wrapper_path
|
||||||
|
var shader := ResourceLoader.load(capture_path, "", ResourceLoader.CACHE_MODE_IGNORE) as Shader
|
||||||
|
_remove_paths(scratch_paths)
|
||||||
|
|
||||||
|
var valid := shader != null and _has_uniform(shader, sentinel)
|
||||||
|
var uniforms: Array[Dictionary] = []
|
||||||
|
var detected_type := shader_type
|
||||||
|
if shader != null:
|
||||||
|
for uniform in shader.get_shader_uniform_list():
|
||||||
|
if str(uniform.get("name", "")) == sentinel:
|
||||||
|
continue
|
||||||
|
uniforms.append(_serialize_uniform(uniform))
|
||||||
|
if kind == "shader":
|
||||||
|
detected_type = str(MODE_NAMES.get(shader.get_mode(), ""))
|
||||||
|
|
||||||
|
var diagnostics: Array[Dictionary] = []
|
||||||
|
var errors: Array[Dictionary] = []
|
||||||
|
var warnings: Array[Dictionary] = []
|
||||||
|
if not valid:
|
||||||
|
var diagnostic := {
|
||||||
|
"level": "error",
|
||||||
|
"text": PARSE_FAILURE_TEXT,
|
||||||
|
"path": "",
|
||||||
|
"line": 0,
|
||||||
|
"function": "",
|
||||||
|
}
|
||||||
|
diagnostics.append(diagnostic)
|
||||||
|
errors.append(diagnostic)
|
||||||
|
return {
|
||||||
|
"valid": valid,
|
||||||
|
"diagnostics": diagnostics,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
"uniforms": uniforms,
|
||||||
|
"shader_type": detected_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Write `code` beside `resource_path` and rename it into place, so a failed
|
||||||
|
## rename never truncates the destination. Validation happens before this call.
|
||||||
|
static func _write_atomic(resource_path: String, code: String, kind: String) -> Variant:
|
||||||
|
var ext := INCLUDE_EXT if kind == "include" else SHADER_EXT
|
||||||
|
var token := "%d-%d" % [OS.get_process_id(), Time.get_ticks_usec()]
|
||||||
|
var temp_path := _scratch_path(resource_path.get_base_dir(), token, ext)
|
||||||
|
var write_err = _write_scratch(temp_path, code)
|
||||||
|
if write_err != null:
|
||||||
|
return write_err
|
||||||
|
var rename_err := DirAccess.rename_absolute(temp_path, resource_path)
|
||||||
|
if rename_err != OK:
|
||||||
|
_remove_if_present(temp_path)
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
"Cannot write %s: %s" % [resource_path, error_string(rename_err)]
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Refresh the resource object already cached for `resource_path` so held
|
||||||
|
## references (ShaderMaterials, shared Shaders, shader include dependencies)
|
||||||
|
## observe the write without waiting for an editor reload. `Shader.set_code()`
|
||||||
|
## re-tracks its includes, and `ShaderInclude.set_code()` emits `changed`, which
|
||||||
|
## recompiles every dependent shader through the engine's own dependency wiring.
|
||||||
|
## A path that was never loaded has no cached identity to refresh.
|
||||||
|
static func _refresh_cached_resource(resource_path: String, code: String, kind: String) -> void:
|
||||||
|
var cached := ResourceLoader.get_cached_ref(resource_path)
|
||||||
|
if cached == null:
|
||||||
|
return
|
||||||
|
if kind == "include":
|
||||||
|
if cached is ShaderInclude:
|
||||||
|
(cached as ShaderInclude).set_code(code)
|
||||||
|
return
|
||||||
|
if cached is Shader:
|
||||||
|
(cached as Shader).set_code(code)
|
||||||
|
|
||||||
|
|
||||||
|
static func _serialize_uniforms(shader: Shader) -> Array[Dictionary]:
|
||||||
|
var out: Array[Dictionary] = []
|
||||||
|
for uniform in shader.get_shader_uniform_list():
|
||||||
|
out.append(_serialize_uniform(uniform))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func _serialize_uniform(uniform: Dictionary) -> Dictionary:
|
||||||
|
var entry := {
|
||||||
|
"name": str(uniform.get("name", "")),
|
||||||
|
"type": type_string(int(uniform.get("type", TYPE_NIL))),
|
||||||
|
"hint": int(uniform.get("hint", 0)),
|
||||||
|
"hint_string": str(uniform.get("hint_string", "")),
|
||||||
|
"usage": int(uniform.get("usage", 0)),
|
||||||
|
}
|
||||||
|
if uniform.has("default_value"):
|
||||||
|
entry["default_value"] = MaterialValues.serialize_value(uniform.get("default_value"))
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
static func _has_uniform(shader: Shader, name: String) -> bool:
|
||||||
|
for uniform in shader.get_shader_uniform_list():
|
||||||
|
if str(uniform.get("name", "")) == name:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
static func _parse_includes(code: String) -> Array[String]:
|
||||||
|
var out: Array[String] = []
|
||||||
|
for raw_line in code.split("\n"):
|
||||||
|
var line := raw_line.strip_edges()
|
||||||
|
if not line.begins_with("#include"):
|
||||||
|
continue
|
||||||
|
var start := line.find("\"")
|
||||||
|
var quote := "\""
|
||||||
|
if start < 0:
|
||||||
|
start = line.find("'")
|
||||||
|
quote = "'"
|
||||||
|
if start < 0:
|
||||||
|
continue
|
||||||
|
var end := line.find(quote, start + 1)
|
||||||
|
if end > start:
|
||||||
|
out.append(line.substr(start + 1, end - start - 1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func _parse_render_modes(code: String) -> Array[String]:
|
||||||
|
var out: Array[String] = []
|
||||||
|
for raw_line in code.split("\n"):
|
||||||
|
var line := raw_line.strip_edges()
|
||||||
|
if not line.begins_with("render_mode"):
|
||||||
|
continue
|
||||||
|
var rest := line.substr("render_mode".length()).strip_edges()
|
||||||
|
if rest.ends_with(";"):
|
||||||
|
rest = rest.substr(0, rest.length() - 1)
|
||||||
|
for mode in rest.split(",", false):
|
||||||
|
var trimmed := mode.strip_edges()
|
||||||
|
if not trimmed.is_empty():
|
||||||
|
out.append(trimmed)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func _scratch_path(directory: String, token: String, ext: String) -> String:
|
||||||
|
var separator := "" if directory.ends_with("/") else "/"
|
||||||
|
return "%s%s.godot-ai-shader-%s.%s" % [directory, separator, token, ext]
|
||||||
|
|
||||||
|
|
||||||
|
static func _kind_for_path(path: String) -> String:
|
||||||
|
match path.get_extension().to_lower():
|
||||||
|
SHADER_EXT:
|
||||||
|
return "shader"
|
||||||
|
INCLUDE_EXT:
|
||||||
|
return "include"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
static func _size_error(code: String) -> Variant:
|
||||||
|
var byte_count := code.to_utf8_buffer().size()
|
||||||
|
if byte_count > MAX_CODE_BYTES:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"code exceeds the %d-byte limit (got %d)" % [MAX_CODE_BYTES, byte_count]
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func _shader_type_error(shader_type: String) -> Variant:
|
||||||
|
if shader_type in SHADER_TYPES:
|
||||||
|
return null
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"shader_type must be one of: %s (got %s)" % [", ".join(SHADER_TYPES), shader_type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
static func _invalid_with_diagnostics(message: String, validation: Dictionary) -> Dictionary:
|
||||||
|
var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
|
||||||
|
err["error"]["data"] = {
|
||||||
|
"diagnostics": validation.get("diagnostics", []),
|
||||||
|
"errors": validation.get("errors", []),
|
||||||
|
"warnings": validation.get("warnings", []),
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
## Scratch-file writer. Parent directories are always known to exist
|
||||||
|
## (destination dir checked by the caller; user:// is guaranteed), so this
|
||||||
|
## stays off McpResourceIO.write_text_to_disk, whose `get_base_dir()` mkdir
|
||||||
|
## path is unreliable for a bare `user://` root.
|
||||||
|
static func _write_scratch(path: String, content: String) -> Variant:
|
||||||
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if file == null:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
"Failed to open scratch file for writing: %s" % path
|
||||||
|
)
|
||||||
|
file.store_string(content)
|
||||||
|
file.flush()
|
||||||
|
var write_err := file.get_error()
|
||||||
|
file.close()
|
||||||
|
if write_err != OK:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
"Scratch write failed for %s: %s" % [path, error_string(write_err)]
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func _remove_paths(paths: Array[String]) -> void:
|
||||||
|
for path in paths:
|
||||||
|
_remove_if_present(path)
|
||||||
|
|
||||||
|
|
||||||
|
static func _remove_if_present(path: String) -> void:
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
DirAccess.remove_absolute(path)
|
||||||
|
|
||||||
|
|
||||||
|
static func _line_count(code: String) -> int:
|
||||||
|
return code.count("\n") + (1 if not code.is_empty() else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ufuy74ofmra1
|
||||||
@@ -13,6 +13,31 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|||||||
|
|
||||||
const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
|
const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
|
||||||
|
|
||||||
|
## Theme constants/font sizes and StyleBoxFlat int properties are 32-bit in the
|
||||||
|
## engine; GDScript ints are 64-bit, so an unvalidated 4294967296 narrows to 0
|
||||||
|
## (and 2147483648 to -2147483648) at the property assignment.
|
||||||
|
const _INT32_MIN := -2147483648
|
||||||
|
const _INT32_MAX := 2147483647
|
||||||
|
|
||||||
|
## Native storage kinds for numeric theme destinations, so validation models
|
||||||
|
## what the engine actually keeps:
|
||||||
|
## _NATIVE_INT32 C++ int32 (Theme constants/font sizes, shadow_size)
|
||||||
|
## _NATIVE_FLOAT32 C++ real_t (margins, shadow offsets, Color components)
|
||||||
|
## _NATIVE_INT32_FLOAT32 int accessors backed by real_t storage: StyleBoxFlat
|
||||||
|
## border widths and corner radii take ints but store
|
||||||
|
## real_t, so 16777217 rounds to 16777216 and
|
||||||
|
## 2147483647 reads back as -2147483648
|
||||||
|
const _NATIVE_INT32 := 0
|
||||||
|
const _NATIVE_FLOAT32 := 1
|
||||||
|
const _NATIVE_INT32_FLOAT32 := 2
|
||||||
|
|
||||||
|
## Top-level keys of the StyleBoxFlat patch vocabulary shared by
|
||||||
|
## set_stylebox_flat and stylebox_override. Nested dicts validate their own
|
||||||
|
## keys inside _apply_flat_props; this guards the outer dict so a typo
|
||||||
|
## (`bg_colour`) cannot install an unchanged override plus an undo entry while
|
||||||
|
## reporting success.
|
||||||
|
const _FLAT_PATCH_KEYS := ["bg_color", "border_color", "border", "corners", "margins", "shadow", "anti_aliasing"]
|
||||||
|
|
||||||
var _undo_redo: EditorUndoRedoManager
|
var _undo_redo: EditorUndoRedoManager
|
||||||
var _connection: McpConnection
|
var _connection: McpConnection
|
||||||
|
|
||||||
@@ -98,7 +123,7 @@ func set_constant(params: Dictionary) -> Dictionary:
|
|||||||
func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
|
func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
|
||||||
func(theme, name, cls): theme.clear_constant(name, cls),
|
func(theme, name, cls): theme.clear_constant(name, cls),
|
||||||
func(theme, name, cls): return theme.has_constant(name, cls),
|
func(theme, name, cls): return theme.has_constant(name, cls),
|
||||||
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
|
func(v): return _parse_int32_value(v))
|
||||||
|
|
||||||
|
|
||||||
func set_font_size(params: Dictionary) -> Dictionary:
|
func set_font_size(params: Dictionary) -> Dictionary:
|
||||||
@@ -106,12 +131,31 @@ func set_font_size(params: Dictionary) -> Dictionary:
|
|||||||
func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
|
func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
|
||||||
func(theme, name, cls): theme.clear_font_size(name, cls),
|
func(theme, name, cls): theme.clear_font_size(name, cls),
|
||||||
func(theme, name, cls): return theme.has_font_size(name, cls),
|
func(theme, name, cls): return theme.has_font_size(name, cls),
|
||||||
func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
|
func(v): return _parse_int32_value(v))
|
||||||
|
|
||||||
|
|
||||||
|
## Theme constant/font_size slots are 32-bit ints. Accepts int/float and
|
||||||
|
## valid-int strings, but rejects non-finite or out-of-int32 values before the
|
||||||
|
## engine narrowing can wrap them (4294967296 -> 0, 2147483648 -> -2147483648).
|
||||||
|
## In-range fractional values keep the existing truncation policy.
|
||||||
|
static func _parse_int32_value(v: Variant) -> Variant:
|
||||||
|
var number: float
|
||||||
|
if v is int or v is float:
|
||||||
|
number = float(v)
|
||||||
|
elif v is String and v.is_valid_int():
|
||||||
|
number = float(v.to_int())
|
||||||
|
else:
|
||||||
|
return null
|
||||||
|
if not is_finite(number) or number < float(_INT32_MIN) or number > float(_INT32_MAX):
|
||||||
|
return null
|
||||||
|
return int(number)
|
||||||
|
|
||||||
|
|
||||||
# Shared implementation for scalar Theme slots (color, constant, font_size).
|
# Shared implementation for scalar Theme slots (color, constant, font_size).
|
||||||
# Captures old value, applies new value, saves to disk, registers undo that
|
# Captures old value, applies new value, persists it as a pre-flight save, then
|
||||||
# restores the old value and saves again.
|
# registers undo that restores the old value and saves again. A failed
|
||||||
|
# pre-flight save restores the old value and reports an error instead of
|
||||||
|
# leaving memory and disk out of sync.
|
||||||
func _set_scalar(
|
func _set_scalar(
|
||||||
params: Dictionary,
|
params: Dictionary,
|
||||||
kind: String,
|
kind: String,
|
||||||
@@ -147,20 +191,34 @@ func _set_scalar(
|
|||||||
var parsed = parser.call(raw_value)
|
var parsed = parser.call(raw_value)
|
||||||
if parsed == null:
|
if parsed == null:
|
||||||
## color slots want a color hint; constant/font_size are integer slots.
|
## color slots want a color hint; constant/font_size are integer slots.
|
||||||
var hint := _COLOR_HINT if kind == "color" else "expected an integer"
|
var hint := _COLOR_HINT if kind == "color" else "expected a 32-bit integer"
|
||||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
"Invalid %s value: %s (%s)" % [kind, raw_value, hint])
|
"Invalid %s value: %s (%s)" % [kind, raw_value, hint])
|
||||||
|
|
||||||
var had_before: bool = has_fn.call(theme, name, class_name_param)
|
var had_before: bool = has_fn.call(theme, name, class_name_param)
|
||||||
var before_value = getter.call(theme, name, class_name_param) if had_before else null
|
var before_value = getter.call(theme, name, class_name_param) if had_before else null
|
||||||
|
|
||||||
|
## Pre-flight: apply and persist before the undo action exists, so an
|
||||||
|
## unwritable theme file (read-only, permissions) fails here with the
|
||||||
|
## cached resource restored instead of reporting success while the file
|
||||||
|
## never changed.
|
||||||
|
var save_err: int = _apply_scalar(theme_path, setter, name, class_name_param, parsed)
|
||||||
|
if save_err != OK:
|
||||||
|
if had_before:
|
||||||
|
_apply_scalar(theme_path, setter, name, class_name_param, before_value)
|
||||||
|
else:
|
||||||
|
_clear_scalar(theme_path, clearer, name, class_name_param)
|
||||||
|
return _save_failure(theme_path, save_err)
|
||||||
|
|
||||||
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
|
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
|
||||||
_undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
|
_undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
|
||||||
if had_before:
|
if had_before:
|
||||||
_undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
|
_undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
|
||||||
else:
|
else:
|
||||||
_undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
|
_undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
|
||||||
_undo_redo.commit_action()
|
## The do method already ran as the pre-flight save; register without
|
||||||
|
## re-executing so a second save's error cannot be discarded.
|
||||||
|
_undo_redo.commit_action(false)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -175,22 +233,33 @@ func _set_scalar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void:
|
func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> int:
|
||||||
var theme: Theme = ResourceLoader.load(theme_path)
|
var theme: Theme = ResourceLoader.load(theme_path)
|
||||||
if theme == null:
|
if theme == null:
|
||||||
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
||||||
return
|
return ERR_FILE_CANT_OPEN
|
||||||
setter.call(theme, name, class_name_param, value)
|
setter.call(theme, name, class_name_param, value)
|
||||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||||
|
|
||||||
|
|
||||||
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void:
|
func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> int:
|
||||||
var theme: Theme = ResourceLoader.load(theme_path)
|
var theme: Theme = ResourceLoader.load(theme_path)
|
||||||
if theme == null:
|
if theme == null:
|
||||||
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
||||||
return
|
return ERR_FILE_CANT_OPEN
|
||||||
clearer.call(theme, name, class_name_param)
|
clearer.call(theme, name, class_name_param)
|
||||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||||
|
|
||||||
|
|
||||||
|
## Error dict for a failed pre-flight save. The caller has already restored the
|
||||||
|
## previous slot in memory, so the theme file and the cached resource both hold
|
||||||
|
## the pre-request state and no undo action was committed.
|
||||||
|
static func _save_failure(theme_path: String, save_err: int) -> Dictionary:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
"Failed to save theme to %s: %s (error %d); the change was rolled back"
|
||||||
|
% [theme_path, error_string(save_err), save_err]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -209,7 +278,14 @@ func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_na
|
|||||||
## anti_aliasing (bool)
|
## anti_aliasing (bool)
|
||||||
##
|
##
|
||||||
## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
|
## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
|
||||||
## typos fail loudly instead of silently being ignored.
|
## typos fail loudly instead of silently being ignored. Numeric values must be
|
||||||
|
## finite and within the native storage's range (32-bit ints, 32-bit floats,
|
||||||
|
## and the float32-backed ints behind border widths/corner radii) and flags
|
||||||
|
## real booleans; invalid input is refused with a structured error before
|
||||||
|
## anything is applied, so a refused call leaves the theme slot and undo
|
||||||
|
## history untouched. A slot change is persisted before the undo action is
|
||||||
|
## registered: if the theme file cannot be written, the previous slot is
|
||||||
|
## restored and the call fails with INTERNAL_ERROR.
|
||||||
func set_stylebox_flat(params: Dictionary) -> Dictionary:
|
func set_stylebox_flat(params: Dictionary) -> Dictionary:
|
||||||
var load_result := _load_theme_from_params(params)
|
var load_result := _load_theme_from_params(params)
|
||||||
if load_result.has("error"):
|
if load_result.has("error"):
|
||||||
@@ -226,81 +302,23 @@ func set_stylebox_flat(params: Dictionary) -> Dictionary:
|
|||||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
|
||||||
|
|
||||||
var sb := StyleBoxFlat.new()
|
var sb := StyleBoxFlat.new()
|
||||||
if params.has("bg_color"):
|
var applied := _apply_flat_props(sb, params)
|
||||||
var bg := _parse_color(params.bg_color)
|
if applied.has("error"):
|
||||||
if bg == null:
|
return applied
|
||||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
|
|
||||||
sb.bg_color = bg
|
|
||||||
if params.has("border_color"):
|
|
||||||
var bc := _parse_color(params.border_color)
|
|
||||||
if bc == null:
|
|
||||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
|
|
||||||
sb.border_color = bc
|
|
||||||
|
|
||||||
# border: {all, top, bottom, left, right} — int widths
|
|
||||||
if params.has("border"):
|
|
||||||
var err := _apply_sides(sb, params.border, "border",
|
|
||||||
["top", "bottom", "left", "right"],
|
|
||||||
"border_width_",
|
|
||||||
TYPE_INT)
|
|
||||||
if err != "":
|
|
||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err)
|
|
||||||
|
|
||||||
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii
|
|
||||||
if params.has("corners"):
|
|
||||||
var err2 := _apply_sides(sb, params.corners, "corners",
|
|
||||||
["top_left", "top_right", "bottom_left", "bottom_right"],
|
|
||||||
"corner_radius_",
|
|
||||||
TYPE_INT)
|
|
||||||
if err2 != "":
|
|
||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2)
|
|
||||||
|
|
||||||
# margins: {all, top, bottom, left, right} — float padding
|
|
||||||
if params.has("margins"):
|
|
||||||
var err3 := _apply_sides(sb, params.margins, "margins",
|
|
||||||
["top", "bottom", "left", "right"],
|
|
||||||
"content_margin_",
|
|
||||||
TYPE_FLOAT)
|
|
||||||
if err3 != "":
|
|
||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3)
|
|
||||||
|
|
||||||
# shadow: {color, size, offset_x, offset_y}
|
|
||||||
if params.has("shadow"):
|
|
||||||
if typeof(params.shadow) != TYPE_DICTIONARY:
|
|
||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
|
|
||||||
var shadow: Dictionary = params.shadow
|
|
||||||
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
|
|
||||||
for k in shadow.keys():
|
|
||||||
if not allowed_shadow_keys.has(k):
|
|
||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
|
||||||
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
|
|
||||||
if shadow.has("color"):
|
|
||||||
var sc := _parse_color(shadow.color)
|
|
||||||
if sc == null:
|
|
||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
|
||||||
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
|
|
||||||
sb.shadow_color = sc
|
|
||||||
if shadow.has("size"):
|
|
||||||
sb.shadow_size = int(shadow.size)
|
|
||||||
if shadow.has("offset_x") or shadow.has("offset_y"):
|
|
||||||
sb.shadow_offset = Vector2(
|
|
||||||
float(shadow.get("offset_x", 0)),
|
|
||||||
float(shadow.get("offset_y", 0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if params.has("anti_aliasing"):
|
|
||||||
sb.anti_aliasing = bool(params.anti_aliasing)
|
|
||||||
|
|
||||||
var had_before := theme.has_stylebox(name, class_name_param)
|
var had_before := theme.has_stylebox(name, class_name_param)
|
||||||
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
|
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
|
||||||
|
## Pre-flight: apply and persist before the undo action exists, so an
|
||||||
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
|
## unwritable theme file fails here with the cached resource restored
|
||||||
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
|
## instead of reporting success while the file never changed.
|
||||||
if had_before:
|
var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
|
||||||
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
|
if save_err != OK:
|
||||||
else:
|
if had_before:
|
||||||
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
|
_apply_stylebox(theme_path, name, class_name_param, before_sb)
|
||||||
_undo_redo.commit_action()
|
else:
|
||||||
|
_clear_stylebox(theme_path, name, class_name_param)
|
||||||
|
return _save_failure(theme_path, save_err)
|
||||||
|
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -332,50 +350,514 @@ func set_stylebox_flat(params: Dictionary) -> Dictionary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
## Parse a {all, <side1>, <side2>, ...} dict and apply it to StyleBoxFlat via
|
## Apply the StyleBoxFlat property vocabulary shared by `set_stylebox_flat`
|
||||||
## its set_<prop_prefix><side> properties. Returns "" on success, an error
|
## and `stylebox_override` to an existing StyleBoxFlat. Returns `{ok: true}`
|
||||||
## message on failure. Validates that only known keys are present.
|
## or the error dict to surface.
|
||||||
func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String,
|
func _apply_flat_props(sb: StyleBoxFlat, params: Dictionary) -> Dictionary:
|
||||||
side_names: Array, prop_prefix: String, value_type: int) -> String:
|
if params.has("bg_color"):
|
||||||
|
var bg := _parse_color(params.bg_color)
|
||||||
|
if bg == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
|
||||||
|
sb.bg_color = bg
|
||||||
|
if params.has("border_color"):
|
||||||
|
var bc := _parse_color(params.border_color)
|
||||||
|
if bc == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
|
||||||
|
sb.border_color = bc
|
||||||
|
|
||||||
|
# border: {all, top, bottom, left, right} — int widths (real_t storage)
|
||||||
|
if params.has("border"):
|
||||||
|
var border_result := _apply_sides(sb, params.border, "border",
|
||||||
|
["top", "bottom", "left", "right"],
|
||||||
|
"border_width_",
|
||||||
|
_NATIVE_INT32_FLOAT32)
|
||||||
|
if border_result.has("error"):
|
||||||
|
return border_result
|
||||||
|
|
||||||
|
# corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii (real_t storage)
|
||||||
|
if params.has("corners"):
|
||||||
|
var corners_result := _apply_sides(sb, params.corners, "corners",
|
||||||
|
["top_left", "top_right", "bottom_left", "bottom_right"],
|
||||||
|
"corner_radius_",
|
||||||
|
_NATIVE_INT32_FLOAT32)
|
||||||
|
if corners_result.has("error"):
|
||||||
|
return corners_result
|
||||||
|
|
||||||
|
# margins: {all, top, bottom, left, right} — float padding
|
||||||
|
if params.has("margins"):
|
||||||
|
var margins_result := _apply_sides(sb, params.margins, "margins",
|
||||||
|
["top", "bottom", "left", "right"],
|
||||||
|
"content_margin_",
|
||||||
|
_NATIVE_FLOAT32)
|
||||||
|
if margins_result.has("error"):
|
||||||
|
return margins_result
|
||||||
|
|
||||||
|
# shadow: {color, size, offset_x, offset_y}
|
||||||
|
if params.has("shadow"):
|
||||||
|
if typeof(params.shadow) != TYPE_DICTIONARY:
|
||||||
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
|
||||||
|
var shadow: Dictionary = params.shadow
|
||||||
|
var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
|
||||||
|
for k in shadow.keys():
|
||||||
|
if not allowed_shadow_keys.has(k):
|
||||||
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
||||||
|
"Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
|
||||||
|
if shadow.has("color"):
|
||||||
|
var sc := _parse_color(shadow.color)
|
||||||
|
if sc == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
||||||
|
"Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
|
||||||
|
sb.shadow_color = sc
|
||||||
|
if shadow.has("size"):
|
||||||
|
var size_result := _parse_number_field("shadow", "size", shadow.size, _NATIVE_INT32)
|
||||||
|
if size_result.has("error"):
|
||||||
|
return size_result
|
||||||
|
sb.shadow_size = size_result.value
|
||||||
|
if shadow.has("offset_x") or shadow.has("offset_y"):
|
||||||
|
## Missing components keep the stylebox's current offset: on the
|
||||||
|
## stylebox_override path `sb` is a duplicate of the resolved
|
||||||
|
## stylebox, and a patch must not silently reset the other axis.
|
||||||
|
var offset_x := sb.shadow_offset.x
|
||||||
|
var offset_y := sb.shadow_offset.y
|
||||||
|
if shadow.has("offset_x"):
|
||||||
|
var offset_x_result := _parse_number_field("shadow", "offset_x", shadow.offset_x, _NATIVE_FLOAT32)
|
||||||
|
if offset_x_result.has("error"):
|
||||||
|
return offset_x_result
|
||||||
|
offset_x = offset_x_result.value
|
||||||
|
if shadow.has("offset_y"):
|
||||||
|
var offset_y_result := _parse_number_field("shadow", "offset_y", shadow.offset_y, _NATIVE_FLOAT32)
|
||||||
|
if offset_y_result.has("error"):
|
||||||
|
return offset_y_result
|
||||||
|
offset_y = offset_y_result.value
|
||||||
|
sb.shadow_offset = Vector2(offset_x, offset_y)
|
||||||
|
|
||||||
|
if params.has("anti_aliasing"):
|
||||||
|
var anti_aliasing_result := _parse_bool_field("anti_aliasing", params.anti_aliasing)
|
||||||
|
if anti_aliasing_result.has("error"):
|
||||||
|
return anti_aliasing_result
|
||||||
|
sb.anti_aliasing = anti_aliasing_result.value
|
||||||
|
return {"ok": true}
|
||||||
|
|
||||||
|
|
||||||
|
## Top-level keys in `patch` that are not part of the StyleBoxFlat vocabulary.
|
||||||
|
static func _unknown_flat_keys(patch: Dictionary) -> Array:
|
||||||
|
var unknown: Array = []
|
||||||
|
for key in patch.keys():
|
||||||
|
if not _FLAT_PATCH_KEYS.has(key):
|
||||||
|
unknown.append(str(key))
|
||||||
|
return unknown
|
||||||
|
|
||||||
|
|
||||||
|
## Record one stylebox set as an undoable action (apply new, restore or clear
|
||||||
|
## the previous slot). Shared by set_stylebox_flat and set_stylebox_texture.
|
||||||
|
## The caller has already applied and persisted the new stylebox as its
|
||||||
|
## pre-flight save, so the action is committed without re-executing the do
|
||||||
|
## method.
|
||||||
|
func _commit_stylebox(
|
||||||
|
theme_path: String, name: String, class_name_param: String,
|
||||||
|
sb: StyleBox, before_sb: StyleBox, had_before: bool
|
||||||
|
) -> void:
|
||||||
|
_undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
|
||||||
|
_undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
|
||||||
|
if had_before:
|
||||||
|
_undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
|
||||||
|
else:
|
||||||
|
_undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
|
||||||
|
_undo_redo.commit_action(false)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# theme_set_stylebox_texture
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Compose a StyleBoxTexture (9-slice) and assign it to a theme slot.
|
||||||
|
##
|
||||||
|
## Parameters (beyond theme_path / class_name / name):
|
||||||
|
## texture_path res:// path to a Texture2D
|
||||||
|
## region {position: {x,y}, size: {x,y}} or [x,y,w,h]
|
||||||
|
## margins {all|left|top|right|bottom: float} texture margins
|
||||||
|
## axis_stretch_horizontal "stretch" | "tile" | "tile_fit"
|
||||||
|
## axis_stretch_vertical "stretch" | "tile" | "tile_fit"
|
||||||
|
## modulate_color Color
|
||||||
|
## draw_center bool
|
||||||
|
##
|
||||||
|
## Numeric values must be finite and within the native storage's range, and
|
||||||
|
## flags real booleans; invalid input is refused with a structured error before
|
||||||
|
## anything is applied. A slot change is persisted before the undo action is
|
||||||
|
## registered: if the theme file cannot be written, the previous slot is
|
||||||
|
## restored and the call fails with INTERNAL_ERROR.
|
||||||
|
func set_stylebox_texture(params: Dictionary) -> Dictionary:
|
||||||
|
var load_result := _load_theme_from_params(params)
|
||||||
|
if load_result.has("error"):
|
||||||
|
return load_result
|
||||||
|
var theme: Theme = load_result.theme
|
||||||
|
var theme_path: String = load_result.path
|
||||||
|
|
||||||
|
var class_name_param: String = params.get("class_name", "")
|
||||||
|
if class_name_param.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
|
||||||
|
var name: String = params.get("name", "")
|
||||||
|
if name.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
|
||||||
|
var texture_path: String = params.get("texture_path", "")
|
||||||
|
if texture_path.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: texture_path")
|
||||||
|
var path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
|
||||||
|
if path_err != null:
|
||||||
|
return path_err
|
||||||
|
if not ResourceLoader.exists(texture_path):
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
|
||||||
|
var texture := ResourceLoader.load(texture_path)
|
||||||
|
if texture == null or not (texture is Texture2D):
|
||||||
|
var got := texture.get_class() if texture != null else "null"
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"Resource at %s is not a Texture2D (got %s)" % [texture_path, got])
|
||||||
|
|
||||||
|
var sb := StyleBoxTexture.new()
|
||||||
|
sb.texture = texture
|
||||||
|
if params.has("region"):
|
||||||
|
var region := _parse_rect2(params.region)
|
||||||
|
if region == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Invalid region: %s (expected {position, size} or [x,y,w,h])" % str(params.region))
|
||||||
|
sb.region_rect = region
|
||||||
|
if params.has("margins"):
|
||||||
|
var margin_result := _apply_texture_margins(sb, params.margins)
|
||||||
|
if margin_result.has("error"):
|
||||||
|
return margin_result
|
||||||
|
if params.has("modulate_color"):
|
||||||
|
var mc := _parse_color(params.modulate_color)
|
||||||
|
if mc == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Invalid modulate_color: %s (%s)" % [str(params.modulate_color), _COLOR_HINT])
|
||||||
|
sb.modulate_color = mc
|
||||||
|
if params.has("draw_center"):
|
||||||
|
var draw_center_result := _parse_bool_field("draw_center", params.draw_center)
|
||||||
|
if draw_center_result.has("error"):
|
||||||
|
return draw_center_result
|
||||||
|
sb.draw_center = draw_center_result.value
|
||||||
|
if params.has("axis_stretch_horizontal"):
|
||||||
|
var ash := _parse_axis_stretch(str(params.axis_stretch_horizontal))
|
||||||
|
if ash == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Invalid axis_stretch_horizontal '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_horizontal))
|
||||||
|
sb.axis_stretch_horizontal = ash
|
||||||
|
if params.has("axis_stretch_vertical"):
|
||||||
|
var asv := _parse_axis_stretch(str(params.axis_stretch_vertical))
|
||||||
|
if asv == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Invalid axis_stretch_vertical '%s'. Valid: stretch, tile, tile_fit" % str(params.axis_stretch_vertical))
|
||||||
|
sb.axis_stretch_vertical = asv
|
||||||
|
|
||||||
|
var had_before := theme.has_stylebox(name, class_name_param)
|
||||||
|
var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
|
||||||
|
## Pre-flight: apply and persist before the undo action exists, so an
|
||||||
|
## unwritable theme file fails here with the cached resource restored
|
||||||
|
## instead of reporting success while the file never changed.
|
||||||
|
var save_err: int = _apply_stylebox(theme_path, name, class_name_param, sb)
|
||||||
|
if save_err != OK:
|
||||||
|
if had_before:
|
||||||
|
_apply_stylebox(theme_path, name, class_name_param, before_sb)
|
||||||
|
else:
|
||||||
|
_clear_stylebox(theme_path, name, class_name_param)
|
||||||
|
return _save_failure(theme_path, save_err)
|
||||||
|
_commit_stylebox(theme_path, name, class_name_param, sb, before_sb, had_before)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"path": theme_path,
|
||||||
|
"class_name": class_name_param,
|
||||||
|
"name": name,
|
||||||
|
"stylebox_class": "StyleBoxTexture",
|
||||||
|
"texture_path": texture_path,
|
||||||
|
"region": _serialize_value(sb.region_rect),
|
||||||
|
"margins": {
|
||||||
|
"left": sb.texture_margin_left,
|
||||||
|
"top": sb.texture_margin_top,
|
||||||
|
"right": sb.texture_margin_right,
|
||||||
|
"bottom": sb.texture_margin_bottom,
|
||||||
|
},
|
||||||
|
"draw_center": sb.draw_center,
|
||||||
|
"undoable": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# theme_set_font / theme_set_icon
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Assign a Font resource to a theme font slot (button/body/heading fonts).
|
||||||
|
func set_font(params: Dictionary) -> Dictionary:
|
||||||
|
return _set_resource_slot(params, "font", "font_path", "Font")
|
||||||
|
|
||||||
|
|
||||||
|
## Assign a Texture2D to a theme icon slot (checkbox marks, dropdown arrows).
|
||||||
|
func set_icon(params: Dictionary) -> Dictionary:
|
||||||
|
return _set_resource_slot(params, "icon", "texture_path", "Texture2D")
|
||||||
|
|
||||||
|
|
||||||
|
## Shared implementation for font/icon slots: load the resource, verify its
|
||||||
|
## class, then set/clear the slot as one undoable action.
|
||||||
|
func _set_resource_slot(
|
||||||
|
params: Dictionary, kind: String, path_param: String, expected_class: String
|
||||||
|
) -> Dictionary:
|
||||||
|
var load_result := _load_theme_from_params(params)
|
||||||
|
if load_result.has("error"):
|
||||||
|
return load_result
|
||||||
|
var theme: Theme = load_result.theme
|
||||||
|
var theme_path: String = load_result.path
|
||||||
|
|
||||||
|
var class_name_param: String = params.get("class_name", "")
|
||||||
|
if class_name_param.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
|
||||||
|
var name: String = params.get("name", "")
|
||||||
|
if name.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
|
||||||
|
var resource_path: String = params.get(path_param, "")
|
||||||
|
if resource_path.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % path_param)
|
||||||
|
var path_err = McpPathValidator.loadable_error(resource_path, path_param)
|
||||||
|
if path_err != null:
|
||||||
|
return path_err
|
||||||
|
if not ResourceLoader.exists(resource_path):
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "%s not found: %s" % [expected_class, resource_path])
|
||||||
|
var loaded := ResourceLoader.load(resource_path)
|
||||||
|
if loaded == null or not _is_instance_of_class(loaded, expected_class):
|
||||||
|
var got := loaded.get_class() if loaded != null else "null"
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"Resource at %s is not a %s (got %s)" % [resource_path, expected_class, got])
|
||||||
|
|
||||||
|
var had_before := theme.has_font(name, class_name_param) if kind == "font" else theme.has_icon(name, class_name_param)
|
||||||
|
var before_value = theme.get_font(name, class_name_param) if kind == "font" else theme.get_icon(name, class_name_param)
|
||||||
|
if not had_before:
|
||||||
|
before_value = null
|
||||||
|
|
||||||
|
## Pre-flight: apply and persist before the undo action exists, so an
|
||||||
|
## unwritable theme file fails here with the cached resource restored
|
||||||
|
## instead of reporting success while the file never changed.
|
||||||
|
var save_err: int = _apply_slot(theme_path, kind, name, class_name_param, loaded)
|
||||||
|
if save_err != OK:
|
||||||
|
if had_before:
|
||||||
|
_apply_slot(theme_path, kind, name, class_name_param, before_value)
|
||||||
|
else:
|
||||||
|
_clear_slot(theme_path, kind, name, class_name_param)
|
||||||
|
return _save_failure(theme_path, save_err)
|
||||||
|
|
||||||
|
_undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
|
||||||
|
_undo_redo.add_do_method(self, "_apply_slot", theme_path, kind, name, class_name_param, loaded)
|
||||||
|
if had_before:
|
||||||
|
_undo_redo.add_undo_method(self, "_apply_slot", theme_path, kind, name, class_name_param, before_value)
|
||||||
|
else:
|
||||||
|
_undo_redo.add_undo_method(self, "_clear_slot", theme_path, kind, name, class_name_param)
|
||||||
|
## The do method already ran as the pre-flight save; register without
|
||||||
|
## re-executing so a second save's error cannot be discarded.
|
||||||
|
_undo_redo.commit_action(false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"path": theme_path,
|
||||||
|
"kind": kind,
|
||||||
|
"class_name": class_name_param,
|
||||||
|
"name": name,
|
||||||
|
"resource_path": resource_path,
|
||||||
|
"resource_class": loaded.get_class(),
|
||||||
|
"undoable": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## True when `resource` is an instance of `class_name` (or a subclass).
|
||||||
|
static func _is_instance_of_class(resource: Resource, expected_class: String) -> bool:
|
||||||
|
return ClassDB.is_parent_class(resource.get_class(), expected_class)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# theme_stylebox_override — per-node override
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Duplicate the stylebox a Control resolves for `slot`, apply a StyleBoxFlat
|
||||||
|
## patch (same keys as set_stylebox_flat), and attach it as a per-node
|
||||||
|
## override. Undo restores the previous override, or removes the override when
|
||||||
|
## the node had none. Patch values are validated the same way as
|
||||||
|
## set_stylebox_flat — unknown top-level keys are refused — and a refused
|
||||||
|
## patch leaves the node's override untouched. The action is committed to the
|
||||||
|
## Control's scene history, so the editor's scene undo reverts it (a method
|
||||||
|
## bound to this handler object would land in GLOBAL_HISTORY instead).
|
||||||
|
func stylebox_override(params: Dictionary) -> Dictionary:
|
||||||
|
var node_path: String = params.get("path", "")
|
||||||
|
if node_path.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
|
||||||
|
var slot: String = params.get("slot", "")
|
||||||
|
if slot.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: slot")
|
||||||
|
var patch: Variant = params.get("patch", {})
|
||||||
|
if typeof(patch) != TYPE_DICTIONARY:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "'patch' must be a dict of StyleBoxFlat properties")
|
||||||
|
var unknown_keys := _unknown_flat_keys(patch)
|
||||||
|
if not unknown_keys.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
||||||
|
"Unknown patch key(s): %s (valid: %s)" % [", ".join(unknown_keys), ", ".join(_FLAT_PATCH_KEYS)])
|
||||||
|
|
||||||
|
var resolved := McpNodeValidator.resolve_or_error(node_path, "path")
|
||||||
|
if resolved.has("error"):
|
||||||
|
return resolved
|
||||||
|
var node: Node = resolved.node
|
||||||
|
if not node is Control:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"Node %s is not a Control (got %s)" % [node_path, node.get_class()])
|
||||||
|
var control := node as Control
|
||||||
|
|
||||||
|
var base: StyleBox = control.get_theme_stylebox(slot)
|
||||||
|
if base == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND,
|
||||||
|
"No stylebox resolves for slot '%s' on %s" % [slot, node.get_class()])
|
||||||
|
var patched: StyleBox = base.duplicate()
|
||||||
|
if not patched is StyleBoxFlat:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"Slot '%s' resolves to %s; stylebox_override patches StyleBoxFlat slots only"
|
||||||
|
% [slot, patched.get_class()])
|
||||||
|
var applied := _apply_flat_props(patched as StyleBoxFlat, patch)
|
||||||
|
if applied.has("error"):
|
||||||
|
return applied
|
||||||
|
|
||||||
|
var had_override: bool = control.has_theme_stylebox_override(slot)
|
||||||
|
var scene_root: Node = resolved.scene_root
|
||||||
|
_undo_redo.create_action("MCP: Stylebox override %s on %s" % [slot, node.name],
|
||||||
|
UndoRedo.MERGE_DISABLE, scene_root)
|
||||||
|
_undo_redo.add_do_method(self, "_apply_node_stylebox", control, slot, patched)
|
||||||
|
if had_override:
|
||||||
|
_undo_redo.add_undo_method(self, "_apply_node_stylebox", control, slot, base)
|
||||||
|
else:
|
||||||
|
_undo_redo.add_undo_method(self, "_remove_node_stylebox", control, slot)
|
||||||
|
_undo_redo.commit_action()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"path": McpScenePath.from_node(node, resolved.scene_root),
|
||||||
|
"slot": slot,
|
||||||
|
"stylebox_class": patched.get_class(),
|
||||||
|
"overrode_existing": had_override,
|
||||||
|
"undoable": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_node_stylebox(control: Control, slot: String, sb: StyleBox) -> void:
|
||||||
|
control.add_theme_stylebox_override(slot, sb)
|
||||||
|
|
||||||
|
|
||||||
|
func _remove_node_stylebox(control: Control, slot: String) -> void:
|
||||||
|
control.remove_theme_stylebox_override(slot)
|
||||||
|
|
||||||
|
|
||||||
|
## Strict finite-number parser for stylebox numeric fields. Accepts int/float
|
||||||
|
## and numeric strings (the McpJsonValues.parse_float vocabulary) and returns
|
||||||
|
## `{"value": <int|float>}`; non-numeric input and non-finite results return a
|
||||||
|
## structured error instead, so a bad value can neither raise an engine
|
||||||
|
## conversion error nor be silently stored as 0.
|
||||||
|
##
|
||||||
|
## The value is validated against the *native destination* before narrowing
|
||||||
|
## (see the _NATIVE_* kinds), and the returned value is the narrowed one, so
|
||||||
|
## what was validated is what gets stored.
|
||||||
|
static func _parse_number_field(dict_name: String, key: String, raw: Variant, storage: int) -> Dictionary:
|
||||||
|
var parsed: Variant = McpJsonValues.parse_float(raw)
|
||||||
|
if parsed == null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"'%s.%s' must be a number, got %s" % [dict_name, key, type_string(typeof(raw))])
|
||||||
|
var number := float(parsed)
|
||||||
|
if not is_finite(number):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"'%s.%s' must be finite, got %s" % [dict_name, key, str(number)])
|
||||||
|
match storage:
|
||||||
|
_NATIVE_INT32:
|
||||||
|
if number < float(_INT32_MIN) or number > float(_INT32_MAX):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"'%s.%s' is out of range for a 32-bit integer (got %s)" % [dict_name, key, str(number)])
|
||||||
|
return {"value": int(number)}
|
||||||
|
_NATIVE_FLOAT32:
|
||||||
|
var narrowed: float = PackedFloat32Array([number])[0]
|
||||||
|
if not is_finite(narrowed):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"'%s.%s' is out of range for a 32-bit float (got %s)" % [dict_name, key, str(number)])
|
||||||
|
return {"value": narrowed}
|
||||||
|
_NATIVE_INT32_FLOAT32:
|
||||||
|
## Must survive the float32 round-trip and stay in int32, or the
|
||||||
|
## int getter reads back a rounded or wrapped value.
|
||||||
|
var narrowed: float = PackedFloat32Array([number])[0]
|
||||||
|
if not is_finite(narrowed) or narrowed < float(_INT32_MIN) or narrowed > float(_INT32_MAX) \
|
||||||
|
or int(narrowed) != int(number):
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"'%s.%s' is out of range for the float-backed 32-bit int storage (got %s)" % [dict_name, key, str(number)])
|
||||||
|
return {"value": int(narrowed)}
|
||||||
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unknown numeric storage kind: %d" % storage)
|
||||||
|
|
||||||
|
|
||||||
|
## Strict bool parser for stylebox flags. `bool("false")` is true in GDScript,
|
||||||
|
## so a stringified flag would silently invert the caller's intent.
|
||||||
|
static func _parse_bool_field(name: String, raw: Variant) -> Dictionary:
|
||||||
|
if raw is bool:
|
||||||
|
return {"value": raw}
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"'%s' must be a boolean, got %s" % [name, type_string(typeof(raw))])
|
||||||
|
|
||||||
|
|
||||||
|
## Parse a {all, <side1>, <side2>, ...} dict into StyleBox numeric properties.
|
||||||
|
## Every value is parsed and validated before anything is assigned, so an
|
||||||
|
## invalid value can neither raise an engine conversion error nor leave a
|
||||||
|
## partially applied stylebox. `sb` is a StyleBoxFlat or StyleBoxTexture (both
|
||||||
|
## expose `set`); `storage` is one of the _NATIVE_* kinds. Returns
|
||||||
|
## {"ok": true} or the error dict to surface.
|
||||||
|
static func _apply_sides(sb: Object, sides_dict: Variant, dict_name: String,
|
||||||
|
side_names: Array, prop_prefix: String, storage: int) -> Dictionary:
|
||||||
if typeof(sides_dict) != TYPE_DICTIONARY:
|
if typeof(sides_dict) != TYPE_DICTIONARY:
|
||||||
return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
|
||||||
|
"'%s' must be a dict with 'all' and/or side-specific keys" % dict_name)
|
||||||
var valid_keys := {"all": true}
|
var valid_keys := {"all": true}
|
||||||
for s in side_names:
|
for s in side_names:
|
||||||
valid_keys[s] = true
|
valid_keys[s] = true
|
||||||
for k in sides_dict.keys():
|
for k in sides_dict.keys():
|
||||||
if not valid_keys.has(k):
|
if not valid_keys.has(k):
|
||||||
return "Unknown key in '%s': %s (valid: all, %s)" % [
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
|
||||||
dict_name, k, ", ".join(side_names)
|
"Unknown key in '%s': %s (valid: all, %s)" % [dict_name, k, ", ".join(side_names)])
|
||||||
]
|
var parsed_values := {}
|
||||||
# Apply `all` first, then override with side-specific keys.
|
|
||||||
if sides_dict.has("all"):
|
if sides_dict.has("all"):
|
||||||
var all_val: Variant = sides_dict.all
|
var all_result := _parse_number_field(dict_name, "all", sides_dict.all, storage)
|
||||||
for s in side_names:
|
if all_result.has("error"):
|
||||||
var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val)
|
return all_result
|
||||||
sb.set(prop_prefix + s, v)
|
parsed_values["all"] = all_result.value
|
||||||
for s in side_names:
|
for s in side_names:
|
||||||
if sides_dict.has(s):
|
if sides_dict.has(s):
|
||||||
var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s])
|
var side_result := _parse_number_field(dict_name, s, sides_dict[s], storage)
|
||||||
sb.set(prop_prefix + s, v2)
|
if side_result.has("error"):
|
||||||
return ""
|
return side_result
|
||||||
|
parsed_values[s] = side_result.value
|
||||||
|
# Every value parsed: apply `all` first, then the side-specific overrides.
|
||||||
|
if parsed_values.has("all"):
|
||||||
|
for s in side_names:
|
||||||
|
sb.set(prop_prefix + s, parsed_values["all"])
|
||||||
|
for s in side_names:
|
||||||
|
if parsed_values.has(s):
|
||||||
|
sb.set(prop_prefix + s, parsed_values[s])
|
||||||
|
return {"ok": true}
|
||||||
|
|
||||||
|
|
||||||
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void:
|
func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> int:
|
||||||
var theme: Theme = ResourceLoader.load(theme_path)
|
var theme: Theme = ResourceLoader.load(theme_path)
|
||||||
if theme == null:
|
if theme == null:
|
||||||
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
||||||
return
|
return ERR_FILE_CANT_OPEN
|
||||||
theme.set_stylebox(name, class_name_param, sb)
|
theme.set_stylebox(name, class_name_param, sb)
|
||||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||||
|
|
||||||
|
|
||||||
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void:
|
func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> int:
|
||||||
var theme: Theme = ResourceLoader.load(theme_path)
|
var theme: Theme = ResourceLoader.load(theme_path)
|
||||||
if theme == null:
|
if theme == null:
|
||||||
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
||||||
return
|
return ERR_FILE_CANT_OPEN
|
||||||
theme.clear_stylebox(name, class_name_param)
|
theme.clear_stylebox(name, class_name_param)
|
||||||
McpResourceIO.guarded_save(theme, theme_path, _connection)
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -461,9 +943,17 @@ static func _validate_res_path(path: String, required_suffix: String, param_name
|
|||||||
## Returns null if the input cannot be parsed.
|
## Returns null if the input cannot be parsed.
|
||||||
## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
|
## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
|
||||||
## support and strict key/component checking, same shapes as every other
|
## support and strict key/component checking, same shapes as every other
|
||||||
## color-accepting handler.
|
## color-accepting handler. Color components are 32-bit floats, so a finite
|
||||||
|
## double like 1e40 narrows to INF at construction; the parsed value is
|
||||||
|
## re-checked for finiteness and refused instead of storing an infinite color.
|
||||||
static func _parse_color(value: Variant) -> Variant:
|
static func _parse_color(value: Variant) -> Variant:
|
||||||
return McpJsonValues.parse_color(value)
|
var parsed: Variant = McpJsonValues.parse_color(value)
|
||||||
|
if parsed == null:
|
||||||
|
return null
|
||||||
|
var color := parsed as Color
|
||||||
|
if not (is_finite(color.r) and is_finite(color.g) and is_finite(color.b) and is_finite(color.a)):
|
||||||
|
return null
|
||||||
|
return color
|
||||||
|
|
||||||
|
|
||||||
static func _serialize_value(value: Variant) -> Variant:
|
static func _serialize_value(value: Variant) -> Variant:
|
||||||
@@ -473,4 +963,94 @@ static func _serialize_value(value: Variant) -> Variant:
|
|||||||
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
|
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
|
||||||
if value is Vector2:
|
if value is Vector2:
|
||||||
return {"x": value.x, "y": value.y}
|
return {"x": value.x, "y": value.y}
|
||||||
|
if value is Rect2:
|
||||||
|
return {
|
||||||
|
"position": {"x": value.position.x, "y": value.position.y},
|
||||||
|
"size": {"x": value.size.x, "y": value.size.y},
|
||||||
|
}
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
## Set a font/icon slot from inside an undo action. Stylebox slots use the
|
||||||
|
## dedicated `_apply_stylebox` path (they need the StyleBox-typed setter).
|
||||||
|
func _apply_slot(theme_path: String, kind: String, name: String, class_name_param: String, value: Variant) -> int:
|
||||||
|
var theme: Theme = ResourceLoader.load(theme_path)
|
||||||
|
if theme == null:
|
||||||
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
||||||
|
return ERR_FILE_CANT_OPEN
|
||||||
|
match kind:
|
||||||
|
"font":
|
||||||
|
theme.set_font(name, class_name_param, value)
|
||||||
|
"icon":
|
||||||
|
theme.set_icon(name, class_name_param, value)
|
||||||
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_slot(theme_path: String, kind: String, name: String, class_name_param: String) -> int:
|
||||||
|
var theme: Theme = ResourceLoader.load(theme_path)
|
||||||
|
if theme == null:
|
||||||
|
push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
|
||||||
|
return ERR_FILE_CANT_OPEN
|
||||||
|
match kind:
|
||||||
|
"font":
|
||||||
|
theme.clear_font(name, class_name_param)
|
||||||
|
"icon":
|
||||||
|
theme.clear_icon(name, class_name_param)
|
||||||
|
return McpResourceIO.guarded_save(theme, theme_path, _connection)
|
||||||
|
|
||||||
|
|
||||||
|
## Parse a 9-slice region from {position: {x,y}, size: {x,y}} or [x,y,w,h].
|
||||||
|
## Non-finite components are rejected (null), so a NaN/INF region can never be
|
||||||
|
## committed to a stylebox slot.
|
||||||
|
static func _parse_rect2(value: Variant) -> Variant:
|
||||||
|
if value is Rect2:
|
||||||
|
return value if _is_finite_rect2(value) else null
|
||||||
|
if value is Dictionary:
|
||||||
|
var d: Dictionary = value
|
||||||
|
if not (d.has("position") and d.has("size")):
|
||||||
|
return null
|
||||||
|
var pos := McpJsonValues.parse_vector2(d.position)
|
||||||
|
var size := McpJsonValues.parse_vector2(d.size)
|
||||||
|
if pos == null or size == null:
|
||||||
|
return null
|
||||||
|
var rect := Rect2(pos, size)
|
||||||
|
return rect if _is_finite_rect2(rect) else null
|
||||||
|
if value is Array:
|
||||||
|
var arr: Array = value
|
||||||
|
if arr.size() != 4:
|
||||||
|
return null
|
||||||
|
for item in arr:
|
||||||
|
if not (item is int or item is float):
|
||||||
|
return null
|
||||||
|
var rect := Rect2(float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
|
||||||
|
return rect if _is_finite_rect2(rect) else null
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_finite_rect2(rect: Rect2) -> bool:
|
||||||
|
return (
|
||||||
|
is_finite(rect.position.x)
|
||||||
|
and is_finite(rect.position.y)
|
||||||
|
and is_finite(rect.size.x)
|
||||||
|
and is_finite(rect.size.y)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## StyleBoxTexture axis stretch mode by name. Returns null on an unknown name.
|
||||||
|
static func _parse_axis_stretch(value: String) -> Variant:
|
||||||
|
match value:
|
||||||
|
"stretch":
|
||||||
|
return StyleBoxTexture.AXIS_STRETCH_MODE_STRETCH
|
||||||
|
"tile":
|
||||||
|
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE
|
||||||
|
"tile_fit":
|
||||||
|
return StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Apply {all, left, top, right, bottom} texture margins to a StyleBoxTexture.
|
||||||
|
## Shares the flat side parser's validate-then-apply contract: every value is
|
||||||
|
## parsed before any margin is assigned. Returns {"ok": true} or the error dict.
|
||||||
|
static func _apply_texture_margins(sb: StyleBoxTexture, margins: Variant) -> Dictionary:
|
||||||
|
return _apply_sides(sb, margins, "margins", ["left", "top", "right", "bottom"],
|
||||||
|
"texture_margin_", _NATIVE_FLOAT32)
|
||||||
|
|||||||
@@ -204,6 +204,62 @@ func set_text(params: Dictionary) -> Dictionary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# set_richtext — RichTextLabel text (BBcode-aware)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
## Set a RichTextLabel's text in one undo action. `bbcode` (default true)
|
||||||
|
## toggles BBcode parsing before the text is written, so "[color=red]HP[/color]"
|
||||||
|
## renders as markup rather than literal characters.
|
||||||
|
func set_richtext(params: Dictionary) -> Dictionary:
|
||||||
|
var node_path: String = params.get("path", "")
|
||||||
|
if node_path.is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
|
||||||
|
|
||||||
|
if not params.has("text"):
|
||||||
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text")
|
||||||
|
var text_value: Variant = params["text"]
|
||||||
|
if typeof(text_value) != TYPE_STRING:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string")
|
||||||
|
|
||||||
|
var resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
|
||||||
|
if resolved.has("error"):
|
||||||
|
return resolved
|
||||||
|
var node: Node = resolved.node
|
||||||
|
if not node is RichTextLabel:
|
||||||
|
return ErrorCodes.make(
|
||||||
|
ErrorCodes.WRONG_TYPE,
|
||||||
|
"Node %s is not a RichTextLabel (got %s)" % [node_path, node.get_class()]
|
||||||
|
)
|
||||||
|
var label := node as RichTextLabel
|
||||||
|
|
||||||
|
if params.has("bbcode") and typeof(params["bbcode"]) != TYPE_BOOL:
|
||||||
|
## Strict: a stringified "false" would coerce to true and silently
|
||||||
|
## render markup as literal text (or the reverse).
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "bbcode must be a boolean")
|
||||||
|
var bbcode: bool = params.get("bbcode", true)
|
||||||
|
var old_text: String = label.text
|
||||||
|
var old_bbcode: bool = label.bbcode_enabled
|
||||||
|
|
||||||
|
_undo_redo.create_action("MCP: Set rich text on %s" % node.name)
|
||||||
|
## bbcode_enabled first: RichTextLabel parses `text` as BBcode only when
|
||||||
|
## the flag is already set.
|
||||||
|
_undo_redo.add_do_property(node, "bbcode_enabled", bbcode)
|
||||||
|
_undo_redo.add_do_property(node, "text", text_value)
|
||||||
|
_undo_redo.add_undo_property(node, "text", old_text)
|
||||||
|
_undo_redo.add_undo_property(node, "bbcode_enabled", old_bbcode)
|
||||||
|
_undo_redo.commit_action()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"path": McpScenePath.from_node(node, resolved.scene_root),
|
||||||
|
"bbcode": bbcode,
|
||||||
|
"length": (text_value as String).length(),
|
||||||
|
"undoable": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# build_layout — declarative nested-dict → Control tree in one undo action
|
# build_layout — declarative nested-dict → Control tree in one undo action
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
uid://c6n50c5gk5fit
|
||||||
+57
-23
@@ -24,6 +24,7 @@ extends VBoxContainer
|
|||||||
const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
|
const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
|
||||||
const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
|
const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
|
||||||
const Client := preload("res://addons/godot_ai/clients/_base.gd")
|
const Client := preload("res://addons/godot_ai/clients/_base.gd")
|
||||||
|
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
||||||
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
||||||
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
|
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
|
||||||
const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd")
|
const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd")
|
||||||
@@ -54,6 +55,7 @@ static var COLOR_AMBER := Color(1.0, 0.75, 0.25)
|
|||||||
|
|
||||||
signal update_requested
|
signal update_requested
|
||||||
signal client_action_requested(client_id: String, action: String)
|
signal client_action_requested(client_id: String, action: String)
|
||||||
|
signal client_action_cancel_requested(client_id: String)
|
||||||
signal client_status_refresh_requested(client_ids: Array[String], force: bool)
|
signal client_status_refresh_requested(client_ids: Array[String], force: bool)
|
||||||
signal status_snapshot_requested
|
signal status_snapshot_requested
|
||||||
signal live_server_probe_requested(port: int)
|
signal live_server_probe_requested(port: int)
|
||||||
@@ -457,7 +459,7 @@ func _build_ui() -> void:
|
|||||||
|
|
||||||
_crash_docs_btn = Button.new()
|
_crash_docs_btn = Button.new()
|
||||||
_crash_docs_btn.text = "How to change the port"
|
_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.tooltip_text = "Open the guide: change ports in Godot AI settings and reconfigure your MCP clients"
|
||||||
_crash_docs_btn.visible = false
|
_crash_docs_btn.visible = false
|
||||||
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
|
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
|
||||||
_crash_panel.add_child(_crash_docs_btn)
|
_crash_panel.add_child(_crash_docs_btn)
|
||||||
@@ -720,13 +722,13 @@ func _build_client_row(client_id: String) -> void:
|
|||||||
|
|
||||||
var configure_btn := Button.new()
|
var configure_btn := Button.new()
|
||||||
configure_btn.text = "Configure"
|
configure_btn.text = "Configure"
|
||||||
configure_btn.pressed.connect(_on_configure_client.bind(client_id))
|
configure_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "configure"))
|
||||||
row.add_child(configure_btn)
|
row.add_child(configure_btn)
|
||||||
|
|
||||||
var remove_btn := Button.new()
|
var remove_btn := Button.new()
|
||||||
remove_btn.text = "Remove"
|
remove_btn.text = "Remove"
|
||||||
remove_btn.visible = false
|
remove_btn.visible = false
|
||||||
remove_btn.pressed.connect(_on_remove_client.bind(client_id))
|
remove_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "remove"))
|
||||||
row.add_child(remove_btn)
|
row.add_child(remove_btn)
|
||||||
|
|
||||||
# F-3-4: use the authoritative facade so Open/Reveal land on the same
|
# F-3-4: use the authoritative facade so Open/Reveal land on the same
|
||||||
@@ -827,6 +829,13 @@ func _update_status() -> void:
|
|||||||
elif bool(server_status.get("handoff_retry_pending", false)):
|
elif bool(server_status.get("handoff_retry_pending", false)):
|
||||||
status_text = "Recovering after update…"
|
status_text = "Recovering after update…"
|
||||||
status_color = COLOR_AMBER
|
status_color = COLOR_AMBER
|
||||||
|
elif state == ServerStateScript.UNSUPPORTED_CONFIG:
|
||||||
|
status_text = "Unsupported remote access configuration"
|
||||||
|
status_color = Color.RED
|
||||||
|
elif state == ServerStateScript.CRASHED and str(server_status.get("reason", "")) == "endpoint_lost":
|
||||||
|
var recovery_pending := bool(server_status.get("recovery_pending", false))
|
||||||
|
status_text = "Connection lost; reconnecting..." if recovery_pending else "Connection lost"
|
||||||
|
status_color = COLOR_AMBER if recovery_pending else Color.RED
|
||||||
elif state == ServerStateScript.CRASHED:
|
elif state == ServerStateScript.CRASHED:
|
||||||
var exit_ms: int = server_status.get("exit_ms", 0)
|
var exit_ms: int = server_status.get("exit_ms", 0)
|
||||||
status_text = "Server exited after %.1fs" % (exit_ms / 1000.0)
|
status_text = "Server exited after %.1fs" % (exit_ms / 1000.0)
|
||||||
@@ -843,7 +852,10 @@ func _update_status() -> void:
|
|||||||
var conflict_port: int = int(server_status.get("conflict_port", 0))
|
var conflict_port: int = int(server_status.get("conflict_port", 0))
|
||||||
if conflict_port <= 0:
|
if conflict_port <= 0:
|
||||||
conflict_port = ClientConfigurator.http_port()
|
conflict_port = ClientConfigurator.http_port()
|
||||||
status_text = "Port %d held by another process" % conflict_port
|
status_text = (
|
||||||
|
"Windows port discovery unavailable" if str(server_status.get("episode_reason", "")) == "port_occupancy_unknown"
|
||||||
|
else "Port %d held by another process" % conflict_port
|
||||||
|
)
|
||||||
status_color = Color.RED
|
status_color = Color.RED
|
||||||
elif state == ServerStateScript.NO_COMMAND:
|
elif state == ServerStateScript.NO_COMMAND:
|
||||||
status_text = "No server command found"
|
status_text = "No server command found"
|
||||||
@@ -980,6 +992,8 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
|||||||
## problem; don't repeat it here. This copy answers "what do I do?".
|
## problem; don't repeat it here. This copy answers "what do I do?".
|
||||||
var port := ClientConfigurator.http_port()
|
var port := ClientConfigurator.http_port()
|
||||||
match state:
|
match state:
|
||||||
|
ServerStateScript.UNSUPPORTED_CONFIG:
|
||||||
|
return str(server_status.get("message", "Use an IPv4 allowlist or clear Allow remote hosts, then reload the plugin."))
|
||||||
ServerStateScript.PORT_EXCLUDED:
|
ServerStateScript.PORT_EXCLUDED:
|
||||||
return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port
|
return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port
|
||||||
ServerStateScript.INCOMPATIBLE:
|
ServerStateScript.INCOMPATIBLE:
|
||||||
@@ -1056,8 +1070,11 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
|||||||
## the client's attach command, so clients must be reconfigured afterwards.
|
## the client's attach command, so clients must be reconfigured afterwards.
|
||||||
## The per-client reconfigure steps live behind the crash panel's docs link.
|
## The per-client reconfigure steps live behind the crash panel's docs link.
|
||||||
static func _free_port_hint(port: int) -> String:
|
static func _free_port_hint(port: int) -> String:
|
||||||
var free_http := ClientConfigurator.suggest_free_port(port + 1)
|
var occupancy := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
||||||
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1)
|
var free_http := ClientConfigurator.suggest_free_port(port + 1, 2048, occupancy)
|
||||||
|
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1, 2048, occupancy)
|
||||||
|
if free_http == 0 or free_ws == 0:
|
||||||
|
return "Automatic port selection is unavailable. Choose HTTP and WS ports manually below, or retry."
|
||||||
return "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [free_http, free_ws]
|
return "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [free_http, free_ws]
|
||||||
|
|
||||||
|
|
||||||
@@ -1745,8 +1762,13 @@ func _on_configure_client(client_id: String) -> void:
|
|||||||
_dispatch_client_action(client_id, "configure")
|
_dispatch_client_action(client_id, "configure")
|
||||||
|
|
||||||
|
|
||||||
func _on_remove_client(client_id: String) -> void:
|
func _on_client_action_button_pressed(client_id: String, action: String) -> void:
|
||||||
_dispatch_client_action(client_id, "remove")
|
if _is_self_update_in_progress():
|
||||||
|
return
|
||||||
|
if _client_work_snapshot.get("action_phases", {}).get(client_id, "") == "queued":
|
||||||
|
client_action_cancel_requested.emit(client_id)
|
||||||
|
return
|
||||||
|
_dispatch_client_action(client_id, action)
|
||||||
|
|
||||||
|
|
||||||
## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner.
|
## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner.
|
||||||
@@ -1771,6 +1793,12 @@ func present_client_action_result(
|
|||||||
) -> void:
|
) -> void:
|
||||||
_report_prewarm_outcome(client_id, prewarm)
|
_report_prewarm_outcome(client_id, prewarm)
|
||||||
_finalize_action_buttons(client_id)
|
_finalize_action_buttons(client_id)
|
||||||
|
if result.get("status") == "cancelled":
|
||||||
|
var row: Dictionary = _client_rows.get(client_id, {})
|
||||||
|
if not row.is_empty():
|
||||||
|
_apply_row_status(client_id, row.get("status", Client.Status.NOT_CONFIGURED))
|
||||||
|
_refresh_clients_summary()
|
||||||
|
return
|
||||||
var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED
|
var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED
|
||||||
if result.get("status") == "ok":
|
if result.get("status") == "ok":
|
||||||
## #877: Remove targets only the selected scope, so a configure is the
|
## #877: Remove targets only the selected scope, so a configure is the
|
||||||
@@ -1805,7 +1833,11 @@ func present_client_work_snapshot(snapshot: Dictionary) -> void:
|
|||||||
var id := String(client_id)
|
var id := String(client_id)
|
||||||
if busy.has(id):
|
if busy.has(id):
|
||||||
_set_row_action_in_flight(id, String(names.get(id, "configure")))
|
_set_row_action_in_flight(id, String(names.get(id, "configure")))
|
||||||
if String(phases.get(id, "")) == "prewarm":
|
if String(phases.get(id, "")) == "queued":
|
||||||
|
var button := "remove_btn" if String(names.get(id, "configure")) == "remove" else "configure_btn"
|
||||||
|
(_client_rows[id][button] as Button).text = "Cancel queued"
|
||||||
|
(_client_rows[id][button] as Button).disabled = false
|
||||||
|
elif String(phases.get(id, "")) == "prewarm":
|
||||||
(_client_rows[id]["configure_btn"] as Button).text = "Installing…"
|
(_client_rows[id]["configure_btn"] as Button).text = "Installing…"
|
||||||
else:
|
else:
|
||||||
_finalize_action_buttons(id)
|
_finalize_action_buttons(id)
|
||||||
@@ -2335,9 +2367,19 @@ func _build_settings_tab(tabs: TabContainer) -> void:
|
|||||||
## `_reset_tools_pending_from_setting` / `_on_open_clients_window`).
|
## `_reset_tools_pending_from_setting` / `_on_open_clients_window`).
|
||||||
var settings_tab := VBoxContainer.new()
|
var settings_tab := VBoxContainer.new()
|
||||||
settings_tab.add_theme_constant_override("separation", 8)
|
settings_tab.add_theme_constant_override("separation", 8)
|
||||||
|
settings_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
## The section stack below (Vision Routing + Remote access) can outgrow
|
||||||
|
## the window's minimum height, especially at larger editor scales —
|
||||||
|
## scroll instead of growing the window, same idiom as the Clients and
|
||||||
|
## Tools tabs (#1090).
|
||||||
|
var settings_scroll := ScrollContainer.new()
|
||||||
|
settings_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
settings_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
settings_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
settings_scroll.add_child(settings_tab)
|
||||||
var settings_margin := _build_margin_container()
|
var settings_margin := _build_margin_container()
|
||||||
settings_margin.name = "Settings"
|
settings_margin.name = "Settings"
|
||||||
settings_margin.add_child(settings_tab)
|
settings_margin.add_child(settings_scroll)
|
||||||
tabs.add_child(settings_margin)
|
tabs.add_child(settings_margin)
|
||||||
|
|
||||||
## Vision Routing is configuration, not status — it lives here rather
|
## Vision Routing is configuration, not status — it lives here rather
|
||||||
@@ -2448,25 +2490,17 @@ func _on_allow_hosts_text_changed(_new_text: String) -> void:
|
|||||||
func _refresh_allow_hosts_ui_state() -> void:
|
func _refresh_allow_hosts_ui_state() -> void:
|
||||||
if _allow_hosts_edit == null or _allow_hosts_apply_btn == null:
|
if _allow_hosts_edit == null or _allow_hosts_apply_btn == null:
|
||||||
return
|
return
|
||||||
var invalid := McpAllowHosts.invalid_tokens(_allow_hosts_edit.text)
|
var error := McpAllowHosts.configuration_error(_allow_hosts_edit.text)
|
||||||
if invalid.is_empty():
|
_allow_hosts_hint.text = error
|
||||||
_allow_hosts_hint.visible = false
|
_allow_hosts_hint.visible = not error.is_empty()
|
||||||
else:
|
_allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not error.is_empty()
|
||||||
## Name the accepted syntax in the hint — matches the server's
|
|
||||||
## `parse_allow_hosts` (CIDR / bare IP, comma-separated).
|
|
||||||
_allow_hosts_hint.text = (
|
|
||||||
"Invalid entries (must be a CIDR like 192.168.1.0/24 or a bare IP, comma-separated): %s"
|
|
||||||
% ", ".join(invalid)
|
|
||||||
)
|
|
||||||
_allow_hosts_hint.visible = true
|
|
||||||
_allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not invalid.is_empty()
|
|
||||||
|
|
||||||
|
|
||||||
func _on_allow_hosts_apply() -> void:
|
func _on_allow_hosts_apply() -> void:
|
||||||
if _allow_hosts_edit == null:
|
if _allow_hosts_edit == null:
|
||||||
return
|
return
|
||||||
var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text)
|
var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text)
|
||||||
if not McpAllowHosts.invalid_tokens(normalized).is_empty():
|
if not McpAllowHosts.configuration_error(normalized).is_empty():
|
||||||
return
|
return
|
||||||
_allow_hosts_saved = normalized
|
_allow_hosts_saved = normalized
|
||||||
_allow_hosts_edit.text = normalized
|
_allow_hosts_edit.text = normalized
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
name="Godot AI"
|
name="Godot AI"
|
||||||
description="MCP server and AI tools for Godot"
|
description="MCP server and AI tools for Godot"
|
||||||
author="Godot AI"
|
author="Godot AI"
|
||||||
version="4.1.0"
|
version="4.2.1"
|
||||||
script="plugin.gd"
|
script="plugin.gd"
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy_handler("theme", HANDLERS_DIR + "theme_handler.gd", [undo, _connection])
|
_dispatcher.register_lazy_handler("theme", HANDLERS_DIR + "theme_handler.gd", [undo, _connection])
|
||||||
_dispatcher.register_lazy_handler("animation", HANDLERS_DIR + "animation_handler.gd", [undo])
|
_dispatcher.register_lazy_handler("animation", HANDLERS_DIR + "animation_handler.gd", [undo])
|
||||||
_dispatcher.register_lazy_handler("material", HANDLERS_DIR + "material_handler.gd", [undo, _connection])
|
_dispatcher.register_lazy_handler("material", HANDLERS_DIR + "material_handler.gd", [undo, _connection])
|
||||||
|
_dispatcher.register_lazy_handler("shader", HANDLERS_DIR + "shader_handler.gd", [])
|
||||||
|
_dispatcher.register_lazy_handler("visual_shader", HANDLERS_DIR + "visual_shader_handler.gd", [])
|
||||||
_dispatcher.register_lazy_handler("particle", HANDLERS_DIR + "particle_handler.gd", [undo])
|
_dispatcher.register_lazy_handler("particle", HANDLERS_DIR + "particle_handler.gd", [undo])
|
||||||
_dispatcher.register_lazy_handler("camera", HANDLERS_DIR + "camera_handler.gd", [undo])
|
_dispatcher.register_lazy_handler("camera", HANDLERS_DIR + "camera_handler.gd", [undo])
|
||||||
_dispatcher.register_lazy_handler("audio", HANDLERS_DIR + "audio_handler.gd", [undo])
|
_dispatcher.register_lazy_handler("audio", HANDLERS_DIR + "audio_handler.gd", [undo])
|
||||||
@@ -378,6 +380,7 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy_handler("tilemap", HANDLERS_DIR + "tilemap_handler.gd", [undo])
|
_dispatcher.register_lazy_handler("tilemap", HANDLERS_DIR + "tilemap_handler.gd", [undo])
|
||||||
_dispatcher.register_lazy_handler("tileset", HANDLERS_DIR + "tileset_handler.gd", [])
|
_dispatcher.register_lazy_handler("tileset", HANDLERS_DIR + "tileset_handler.gd", [])
|
||||||
_dispatcher.register_lazy_handler("gridmap", HANDLERS_DIR + "gridmap_handler.gd", [undo])
|
_dispatcher.register_lazy_handler("gridmap", HANDLERS_DIR + "gridmap_handler.gd", [undo])
|
||||||
|
_dispatcher.register_lazy_handler("navigation", HANDLERS_DIR + "navigation_handler.gd", [undo, _connection])
|
||||||
_dispatcher.register_lazy_handler("csg", HANDLERS_DIR + "csg_handler.gd", [undo])
|
_dispatcher.register_lazy_handler("csg", HANDLERS_DIR + "csg_handler.gd", [undo])
|
||||||
|
|
||||||
_dispatcher.register_lazy("get_editor_state", "editor", &"get_editor_state")
|
_dispatcher.register_lazy("get_editor_state", "editor", &"get_editor_state")
|
||||||
@@ -428,6 +431,7 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy("find_symbols", "script", &"find_symbols")
|
_dispatcher.register_lazy("find_symbols", "script", &"find_symbols")
|
||||||
_dispatcher.register_lazy("search_resources", "resource", &"search_resources")
|
_dispatcher.register_lazy("search_resources", "resource", &"search_resources")
|
||||||
_dispatcher.register_lazy("load_resource", "resource", &"load_resource")
|
_dispatcher.register_lazy("load_resource", "resource", &"load_resource")
|
||||||
|
_dispatcher.register_lazy("inspect_resource", "resource", &"inspect_resource")
|
||||||
_dispatcher.register_lazy("assign_resource", "resource", &"assign_resource")
|
_dispatcher.register_lazy("assign_resource", "resource", &"assign_resource")
|
||||||
_dispatcher.register_lazy("create_resource", "resource", &"create_resource")
|
_dispatcher.register_lazy("create_resource", "resource", &"create_resource")
|
||||||
_dispatcher.register_lazy("get_resource_info", "resource", &"get_resource_info")
|
_dispatcher.register_lazy("get_resource_info", "resource", &"get_resource_info")
|
||||||
@@ -436,6 +440,9 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy("write_file", "filesystem", &"write_file")
|
_dispatcher.register_lazy("write_file", "filesystem", &"write_file")
|
||||||
_dispatcher.register_lazy("reimport", "filesystem", &"reimport")
|
_dispatcher.register_lazy("reimport", "filesystem", &"reimport")
|
||||||
_dispatcher.register_lazy("scan_filesystem", "filesystem", &"scan_filesystem")
|
_dispatcher.register_lazy("scan_filesystem", "filesystem", &"scan_filesystem")
|
||||||
|
_dispatcher.register_lazy("move_file", "filesystem", &"move_file")
|
||||||
|
_dispatcher.register_lazy("rename_file", "filesystem", &"rename_file")
|
||||||
|
_dispatcher.register_lazy("remove_file", "filesystem", &"remove_file")
|
||||||
_dispatcher.register_lazy("list_signals", "signal", &"list_signals")
|
_dispatcher.register_lazy("list_signals", "signal", &"list_signals")
|
||||||
_dispatcher.register_lazy("connect_signal", "signal", &"connect_signal")
|
_dispatcher.register_lazy("connect_signal", "signal", &"connect_signal")
|
||||||
_dispatcher.register_lazy("disconnect_signal", "signal", &"disconnect_signal")
|
_dispatcher.register_lazy("disconnect_signal", "signal", &"disconnect_signal")
|
||||||
@@ -453,12 +460,17 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy("batch_execute", "batch", &"batch_execute")
|
_dispatcher.register_lazy("batch_execute", "batch", &"batch_execute")
|
||||||
_dispatcher.register_lazy("set_anchor_preset", "ui", &"set_anchor_preset")
|
_dispatcher.register_lazy("set_anchor_preset", "ui", &"set_anchor_preset")
|
||||||
_dispatcher.register_lazy("set_text", "ui", &"set_text")
|
_dispatcher.register_lazy("set_text", "ui", &"set_text")
|
||||||
|
_dispatcher.register_lazy("set_richtext", "ui", &"set_richtext")
|
||||||
_dispatcher.register_lazy("build_layout", "ui", &"build_layout")
|
_dispatcher.register_lazy("build_layout", "ui", &"build_layout")
|
||||||
_dispatcher.register_lazy("create_theme", "theme", &"create_theme")
|
_dispatcher.register_lazy("create_theme", "theme", &"create_theme")
|
||||||
_dispatcher.register_lazy("theme_set_color", "theme", &"set_color")
|
_dispatcher.register_lazy("theme_set_color", "theme", &"set_color")
|
||||||
_dispatcher.register_lazy("theme_set_constant", "theme", &"set_constant")
|
_dispatcher.register_lazy("theme_set_constant", "theme", &"set_constant")
|
||||||
_dispatcher.register_lazy("theme_set_font_size", "theme", &"set_font_size")
|
_dispatcher.register_lazy("theme_set_font_size", "theme", &"set_font_size")
|
||||||
_dispatcher.register_lazy("theme_set_stylebox_flat", "theme", &"set_stylebox_flat")
|
_dispatcher.register_lazy("theme_set_stylebox_flat", "theme", &"set_stylebox_flat")
|
||||||
|
_dispatcher.register_lazy("theme_set_stylebox_texture", "theme", &"set_stylebox_texture")
|
||||||
|
_dispatcher.register_lazy("theme_set_font", "theme", &"set_font")
|
||||||
|
_dispatcher.register_lazy("theme_set_icon", "theme", &"set_icon")
|
||||||
|
_dispatcher.register_lazy("theme_stylebox_override", "theme", &"stylebox_override")
|
||||||
_dispatcher.register_lazy("apply_theme", "theme", &"apply_theme")
|
_dispatcher.register_lazy("apply_theme", "theme", &"apply_theme")
|
||||||
_dispatcher.register_lazy("animation_player_create", "animation", &"create_player")
|
_dispatcher.register_lazy("animation_player_create", "animation", &"create_player")
|
||||||
_dispatcher.register_lazy("animation_create", "animation", &"create_animation")
|
_dispatcher.register_lazy("animation_create", "animation", &"create_animation")
|
||||||
@@ -477,6 +489,10 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy("animation_preset_shake", "animation", &"preset_shake")
|
_dispatcher.register_lazy("animation_preset_shake", "animation", &"preset_shake")
|
||||||
_dispatcher.register_lazy("animation_preset_pulse", "animation", &"preset_pulse")
|
_dispatcher.register_lazy("animation_preset_pulse", "animation", &"preset_pulse")
|
||||||
_dispatcher.register_lazy("material_create", "material", &"create_material")
|
_dispatcher.register_lazy("material_create", "material", &"create_material")
|
||||||
|
_dispatcher.register_lazy("visual_shader_create_graph", "visual_shader", &"create_graph")
|
||||||
|
_dispatcher.register_lazy("visual_shader_get", "visual_shader", &"get_graph")
|
||||||
|
_dispatcher.register_lazy("visual_shader_node_catalog", "visual_shader", &"node_catalog")
|
||||||
|
_dispatcher.register_lazy("visual_shader_edit", "visual_shader", &"edit_graph")
|
||||||
_dispatcher.register_lazy("material_set_param", "material", &"set_param")
|
_dispatcher.register_lazy("material_set_param", "material", &"set_param")
|
||||||
_dispatcher.register_lazy("material_set_shader_param", "material", &"set_shader_param")
|
_dispatcher.register_lazy("material_set_shader_param", "material", &"set_shader_param")
|
||||||
_dispatcher.register_lazy("material_get", "material", &"get_material")
|
_dispatcher.register_lazy("material_get", "material", &"get_material")
|
||||||
@@ -484,6 +500,10 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy("material_assign", "material", &"assign_material")
|
_dispatcher.register_lazy("material_assign", "material", &"assign_material")
|
||||||
_dispatcher.register_lazy("material_apply_to_node", "material", &"apply_to_node")
|
_dispatcher.register_lazy("material_apply_to_node", "material", &"apply_to_node")
|
||||||
_dispatcher.register_lazy("material_apply_preset", "material", &"apply_preset")
|
_dispatcher.register_lazy("material_apply_preset", "material", &"apply_preset")
|
||||||
|
_dispatcher.register_lazy("shader_create", "shader", &"create_shader")
|
||||||
|
_dispatcher.register_lazy("shader_get", "shader", &"get_shader")
|
||||||
|
_dispatcher.register_lazy("shader_validate", "shader", &"validate_shader")
|
||||||
|
_dispatcher.register_lazy("shader_patch", "shader", &"patch_shader")
|
||||||
_dispatcher.register_lazy("particle_create", "particle", &"create_particle")
|
_dispatcher.register_lazy("particle_create", "particle", &"create_particle")
|
||||||
_dispatcher.register_lazy("particle_set_main", "particle", &"set_main")
|
_dispatcher.register_lazy("particle_set_main", "particle", &"set_main")
|
||||||
_dispatcher.register_lazy("particle_set_process", "particle", &"set_process")
|
_dispatcher.register_lazy("particle_set_process", "particle", &"set_process")
|
||||||
@@ -523,6 +543,8 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer")
|
_dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer")
|
||||||
_dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells")
|
_dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells")
|
||||||
_dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items")
|
_dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items")
|
||||||
|
_dispatcher.register_lazy("navigation_bake", "navigation", &"bake")
|
||||||
|
_dispatcher.register_lazy("navigation_path_get", "navigation", &"path_get")
|
||||||
_dispatcher.register_lazy("csg_create", "csg", &"create")
|
_dispatcher.register_lazy("csg_create", "csg", &"create")
|
||||||
_dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation")
|
_dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation")
|
||||||
|
|
||||||
@@ -545,6 +567,7 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
_dock.name = "Godot AI"
|
_dock.name = "Godot AI"
|
||||||
_dock.update_requested.connect(_on_dock_update_requested)
|
_dock.update_requested.connect(_on_dock_update_requested)
|
||||||
_dock.client_action_requested.connect(_on_dock_client_action_requested)
|
_dock.client_action_requested.connect(_on_dock_client_action_requested)
|
||||||
|
_dock.client_action_cancel_requested.connect(_on_dock_client_action_cancel_requested)
|
||||||
_dock.client_status_refresh_requested.connect(_on_dock_client_status_refresh_requested)
|
_dock.client_status_refresh_requested.connect(_on_dock_client_status_refresh_requested)
|
||||||
_dock.status_snapshot_requested.connect(_on_dock_status_snapshot_requested)
|
_dock.status_snapshot_requested.connect(_on_dock_status_snapshot_requested)
|
||||||
_dock.live_server_probe_requested.connect(_on_dock_live_server_probe_requested)
|
_dock.live_server_probe_requested.connect(_on_dock_live_server_probe_requested)
|
||||||
@@ -569,18 +592,24 @@ func _continue_enter_tree_after_update_barrier() -> void:
|
|||||||
## client migration worker exists. An old bridge keeps its own ports.
|
## client migration worker exists. An old bridge keeps its own ports.
|
||||||
func _activate_startup_endpoints() -> void:
|
func _activate_startup_endpoints() -> void:
|
||||||
if str(_post_update_outcome.get("outcome", "")) == "success":
|
if str(_post_update_outcome.get("outcome", "")) == "success":
|
||||||
|
var prepare_started := _startup_trace_call_begin("prepare_upgrade_endpoints")
|
||||||
var prepared := ClientConfigurator.prepare_major_upgrade_endpoints(
|
var prepared := ClientConfigurator.prepare_major_upgrade_endpoints(
|
||||||
str(_post_update_outcome.get("from_version", "")),
|
str(_post_update_outcome.get("from_version", "")),
|
||||||
str(_post_update_outcome.get("to_version", "")),
|
str(_post_update_outcome.get("to_version", "")),
|
||||||
)
|
)
|
||||||
|
_startup_trace_call_end("prepare_upgrade_endpoints", prepare_started)
|
||||||
if not bool(prepared.get("ok", false)):
|
if not bool(prepared.get("ok", false)):
|
||||||
_present_endpoint_setup_failure(str(prepared.get("error", "Endpoint selection failed.")))
|
_present_endpoint_setup_failure(str(prepared.get("error", "Endpoint selection failed.")))
|
||||||
return
|
return
|
||||||
|
var override_started := _startup_trace_call_begin("endpoint_override")
|
||||||
var override := ClientConfigurator.v4_endpoint_ports_status()
|
var override := ClientConfigurator.v4_endpoint_ports_status()
|
||||||
|
_startup_trace_call_end("endpoint_override", override_started)
|
||||||
if not bool(override.get("ok", false)):
|
if not bool(override.get("ok", false)):
|
||||||
_present_endpoint_setup_failure(str(override.get("error", "Invalid endpoint override.")))
|
_present_endpoint_setup_failure(str(override.get("error", "Invalid endpoint override.")))
|
||||||
return
|
return
|
||||||
|
var policy_started := _startup_trace_call_begin("endpoint_policy")
|
||||||
var resolved_policy := ClientConfigurator.capture_endpoint_policy()
|
var resolved_policy := ClientConfigurator.capture_endpoint_policy()
|
||||||
|
_startup_trace_call_end("endpoint_policy", policy_started)
|
||||||
var http_port := int(resolved_policy.http_port)
|
var http_port := int(resolved_policy.http_port)
|
||||||
var configured_ws := int(resolved_policy.ws_port)
|
var configured_ws := int(resolved_policy.ws_port)
|
||||||
if (
|
if (
|
||||||
@@ -590,7 +619,9 @@ func _activate_startup_endpoints() -> void:
|
|||||||
):
|
):
|
||||||
_present_endpoint_setup_failure("Choose distinct HTTP and WebSocket ports between %d and %d in Godot AI settings." % [ClientConfigurator.MIN_PORT, ClientConfigurator.MAX_PORT])
|
_present_endpoint_setup_failure("Choose distinct HTTP and WebSocket ports between %d and %d in Godot AI settings." % [ClientConfigurator.MIN_PORT, ClientConfigurator.MAX_PORT])
|
||||||
return
|
return
|
||||||
|
var ws_started := _startup_trace_call_begin("resolve_ws_port")
|
||||||
var resolved_ws := _resolve_ws_port(configured_ws)
|
var resolved_ws := _resolve_ws_port(configured_ws)
|
||||||
|
_startup_trace_call_end("resolve_ws_port", ws_started)
|
||||||
if (
|
if (
|
||||||
resolved_ws < ClientConfigurator.MIN_PORT or resolved_ws > ClientConfigurator.MAX_PORT
|
resolved_ws < ClientConfigurator.MIN_PORT or resolved_ws > ClientConfigurator.MAX_PORT
|
||||||
or resolved_ws == http_port
|
or resolved_ws == http_port
|
||||||
@@ -599,7 +630,9 @@ func _activate_startup_endpoints() -> void:
|
|||||||
_present_endpoint_setup_failure("The configured WebSocket port is unavailable. Choose another endpoint pair in Godot AI settings, then retry.")
|
_present_endpoint_setup_failure("The configured WebSocket port is unavailable. Choose another endpoint pair in Godot AI settings, then retry.")
|
||||||
return
|
return
|
||||||
resolved_policy["ws_port"] = resolved_ws
|
resolved_policy["ws_port"] = resolved_ws
|
||||||
|
var capability_started := _startup_trace_call_begin("capability_path")
|
||||||
resolved_policy["capability_path"] = TransportCapability.path_for_http_port(http_port)
|
resolved_policy["capability_path"] = TransportCapability.path_for_http_port(http_port)
|
||||||
|
_startup_trace_call_end("capability_path", capability_started)
|
||||||
_set_endpoint_policy(resolved_policy)
|
_set_endpoint_policy(resolved_policy)
|
||||||
if _connection != null:
|
if _connection != null:
|
||||||
_connection.ws_port = resolved_ws
|
_connection.ws_port = resolved_ws
|
||||||
@@ -608,9 +641,16 @@ func _activate_startup_endpoints() -> void:
|
|||||||
if _dock != null:
|
if _dock != null:
|
||||||
_dock.present_update_state({"post_update_action": "", "status_text": "", "label_text": "", "banner_visible": false})
|
_dock.present_update_state({"post_update_action": "", "status_text": "", "label_text": "", "banner_visible": false})
|
||||||
## #691: publish every environment/setting value before the first worker.
|
## #691: publish every environment/setting value before the first worker.
|
||||||
|
var warm_started := _startup_trace_call_begin("warm_env_snapshot")
|
||||||
ClientConfigurator.warm_env_snapshot(_endpoint_policy)
|
ClientConfigurator.warm_env_snapshot(_endpoint_policy)
|
||||||
_lifecycle.configure(_capture_lifecycle_plan())
|
_startup_trace_call_end("warm_env_snapshot", warm_started)
|
||||||
|
var plan := _capture_lifecycle_plan()
|
||||||
|
var configure_started := _startup_trace_call_begin("lifecycle_configure")
|
||||||
|
_lifecycle.configure(plan)
|
||||||
|
_startup_trace_call_end("lifecycle_configure", configure_started)
|
||||||
|
var release_started := _startup_trace_call_begin("startup_release")
|
||||||
_begin_startup_release()
|
_begin_startup_release()
|
||||||
|
_startup_trace_call_end("startup_release", release_started)
|
||||||
|
|
||||||
|
|
||||||
func _present_endpoint_setup_failure(error: String) -> void:
|
func _present_endpoint_setup_failure(error: String) -> void:
|
||||||
@@ -641,6 +681,14 @@ func _on_dock_client_action_requested(client_id: String, action: String) -> void
|
|||||||
_dock.present_client_work_snapshot(_client_jobs.snapshot())
|
_dock.present_client_work_snapshot(_client_jobs.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_dock_client_action_cancel_requested(client_id: String) -> void:
|
||||||
|
if _client_jobs == null:
|
||||||
|
return
|
||||||
|
_client_jobs.cancel_pending_action(client_id)
|
||||||
|
if _dock != null:
|
||||||
|
_dock.present_client_work_snapshot(_client_jobs.snapshot())
|
||||||
|
|
||||||
|
|
||||||
func _on_dock_client_status_refresh_requested(client_ids: Array[String], force: bool) -> void:
|
func _on_dock_client_status_refresh_requested(client_ids: Array[String], force: bool) -> void:
|
||||||
if _client_jobs == null:
|
if _client_jobs == null:
|
||||||
return
|
return
|
||||||
@@ -1287,6 +1335,26 @@ func _startup_trace_phase(name: String) -> void:
|
|||||||
_startup_trace_last_ms = now
|
_startup_trace_last_ms = now
|
||||||
|
|
||||||
|
|
||||||
|
## Call timings leave the coarse phase clock unchanged. A begin without an end
|
||||||
|
## identifies an unfinished synchronous call, not its underlying cause.
|
||||||
|
func _startup_trace_call_begin(name: String) -> int:
|
||||||
|
if not _startup_trace_enabled:
|
||||||
|
return 0
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
print("MCP startup trace | call=%s begin total_ms=%d" % [name, now - _startup_trace_start_ms])
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
func _startup_trace_call_end(name: String, started_ms: int) -> void:
|
||||||
|
if not _startup_trace_enabled:
|
||||||
|
return
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
print(
|
||||||
|
"MCP startup trace | call=%s end elapsed_ms=%d total_ms=%d"
|
||||||
|
% [name, now - started_ms, now - _startup_trace_start_ms]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _startup_trace_finish(path: String) -> void:
|
func _startup_trace_finish(path: String) -> void:
|
||||||
if not _startup_trace_enabled:
|
if not _startup_trace_enabled:
|
||||||
return
|
return
|
||||||
@@ -1316,19 +1384,30 @@ func _capture_lifecycle_plan() -> Dictionary:
|
|||||||
var policy := _endpoint_policy.duplicate(true)
|
var policy := _endpoint_policy.duplicate(true)
|
||||||
var http_port := int(policy.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT))
|
var http_port := int(policy.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT))
|
||||||
var worktree_src := ""
|
var worktree_src := ""
|
||||||
|
var worktree_started := _startup_trace_call_begin("worktree_source")
|
||||||
if ClientConfigurator.is_dev_checkout():
|
if ClientConfigurator.is_dev_checkout():
|
||||||
worktree_src = ClientConfigurator.find_worktree_src_dir(
|
worktree_src = ClientConfigurator.find_worktree_src_dir(
|
||||||
ProjectSettings.globalize_path("res://")
|
ProjectSettings.globalize_path("res://")
|
||||||
)
|
)
|
||||||
|
_startup_trace_call_end("worktree_source", worktree_started)
|
||||||
|
var expected_version := ClientConfigurator.get_plugin_version()
|
||||||
|
var command_started := _startup_trace_call_begin("server_command")
|
||||||
|
var server_command := ClientConfigurator.get_server_command()
|
||||||
|
_startup_trace_call_end("server_command", command_started)
|
||||||
|
var pid_file := ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE)
|
||||||
|
var startup_report := ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT)
|
||||||
|
var reservation_started := _startup_trace_call_begin("http_port_reservation")
|
||||||
|
var http_port_reserved := WindowsPortReservation.is_port_excluded(http_port)
|
||||||
|
_startup_trace_call_end("http_port_reservation", reservation_started)
|
||||||
return {
|
return {
|
||||||
"http_port": http_port,
|
"http_port": http_port,
|
||||||
"capability_path": str(policy.get("capability_path", "")),
|
"capability_path": str(policy.get("capability_path", "")),
|
||||||
"ws_port": int(policy.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)),
|
"ws_port": int(policy.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)),
|
||||||
"expected_version": ClientConfigurator.get_plugin_version(),
|
"expected_version": expected_version,
|
||||||
"server_command": ClientConfigurator.get_server_command(),
|
"server_command": server_command,
|
||||||
"pid_file": ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE),
|
"pid_file": pid_file,
|
||||||
"startup_report": ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT),
|
"startup_report": startup_report,
|
||||||
"http_port_reserved": WindowsPortReservation.is_port_excluded(http_port),
|
"http_port_reserved": http_port_reserved,
|
||||||
"excluded_domains": str(policy.get("excluded_domains", "")),
|
"excluded_domains": str(policy.get("excluded_domains", "")),
|
||||||
"allow_hosts": str(policy.get("allow_hosts", "")),
|
"allow_hosts": str(policy.get("allow_hosts", "")),
|
||||||
"keep_alive": bool(policy.get("keep_alive", false)),
|
"keep_alive": bool(policy.get("keep_alive", false)),
|
||||||
@@ -1744,7 +1823,16 @@ func restart_or_start_managed_server() -> bool:
|
|||||||
_lifecycle.force_restart_server()
|
_lifecycle.force_restart_server()
|
||||||
return true
|
return true
|
||||||
var port := ClientConfigurator.http_port()
|
var port := ClientConfigurator.http_port()
|
||||||
if PortResolver.is_port_in_use(port):
|
var occupied: bool
|
||||||
|
if OS.get_name() == "Windows":
|
||||||
|
var occupancy := PortResolver.windows_port_occupancy(port)
|
||||||
|
if occupancy == PortResolver.PortOccupancy.UNKNOWN:
|
||||||
|
_lifecycle.start_server()
|
||||||
|
return true
|
||||||
|
occupied = occupancy == PortResolver.PortOccupancy.OCCUPIED
|
||||||
|
else:
|
||||||
|
occupied = PortResolver.is_port_in_use(port)
|
||||||
|
if occupied:
|
||||||
push_warning(
|
push_warning(
|
||||||
"MCP | refusing to restart the unowned server on port %d; stop it from its launcher"
|
"MCP | refusing to restart the unowned server on port %d; stop it from its launcher"
|
||||||
% port
|
% port
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ func _capture_and_reply(
|
|||||||
_reply_error(request_id, "Captured an empty image from game viewport")
|
_reply_error(request_id, "Captured an empty image from game viewport")
|
||||||
return
|
return
|
||||||
|
|
||||||
var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution)
|
var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution, viewport.use_hdr_2d)
|
||||||
var frames_drawn := Engine.get_frames_drawn()
|
var frames_drawn := Engine.get_frames_drawn()
|
||||||
var stale := frames_drawn <= frames_at_request
|
var stale := frames_drawn <= frames_at_request
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const DOMAINS := [
|
|||||||
{"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]},
|
{"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]},
|
||||||
{"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]},
|
{"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]},
|
||||||
{"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]},
|
{"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]},
|
||||||
|
{"id": "navigation", "label": "navigation", "count": 1, "tools": ["navigation_manage"]},
|
||||||
{"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]},
|
{"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]},
|
||||||
{"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]},
|
{"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]},
|
||||||
{"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]},
|
{"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]},
|
||||||
|
|||||||
@@ -168,3 +168,17 @@ static func _is_private_ipv4(addr: String) -> bool:
|
|||||||
var second := int(addr.get_slice(".", 1))
|
var second := int(addr.get_slice(".", 1))
|
||||||
return second >= 16 and second <= 31
|
return second >= 16 and second <= 31
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Syntax and bind-family errors shared by settings and saved launch plans.
|
||||||
|
static func configuration_error(raw: String, platform: String = OS.get_name()) -> String:
|
||||||
|
var invalid := invalid_tokens(raw)
|
||||||
|
if not invalid.is_empty():
|
||||||
|
return "Invalid entries (must be a CIDR or bare IP, comma-separated): %s" % ", ".join(invalid)
|
||||||
|
var value := normalize(raw)
|
||||||
|
if platform != "Windows" or value.is_empty():
|
||||||
|
return ""
|
||||||
|
for token in value.split(","):
|
||||||
|
if not token.contains(":"):
|
||||||
|
return ""
|
||||||
|
return "IPv6-only remote access is unsupported on Windows: the editor and attach bridge require IPv4 loopback. Use an IPv4 allowlist or clear Allow remote hosts to disable remote access. Full IPv6 support is tracked in #1072."
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ extends Node
|
|||||||
## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out
|
## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out
|
||||||
## probe or package pre-warm can outlive the panel that requested it, and
|
## probe or package pre-warm can outlive the panel that requested it, and
|
||||||
## destroying a live Godot Thread corrupts the GDScript VM. This node therefore
|
## destroying a live Godot Thread corrupts the GDScript VM. This node therefore
|
||||||
## owns both worker pools, their cancellation, phases, and one action slot per
|
## owns both worker pools, their cancellation, phases, and one mutation slot
|
||||||
## client until its Thread has been joined. It publishes copied value snapshots
|
## until its Thread has been joined. It publishes copied value snapshots
|
||||||
## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager.
|
## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager.
|
||||||
|
|
||||||
const Client := preload("res://addons/godot_ai/clients/_base.gd")
|
const Client := preload("res://addons/godot_ai/clients/_base.gd")
|
||||||
@@ -57,6 +57,7 @@ var _refresh_generation := 0
|
|||||||
var _mcp_status_waiters: Dictionary = {}
|
var _mcp_status_waiters: Dictionary = {}
|
||||||
|
|
||||||
var _action_threads: Dictionary = {}
|
var _action_threads: Dictionary = {}
|
||||||
|
var _pending_actions: Array[Dictionary] = []
|
||||||
var _action_started_msec: Dictionary = {}
|
var _action_started_msec: Dictionary = {}
|
||||||
var _action_names: Dictionary = {}
|
var _action_names: Dictionary = {}
|
||||||
var _action_request_ids: Dictionary = {}
|
var _action_request_ids: Dictionary = {}
|
||||||
@@ -106,7 +107,7 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _has_work_in_flight() -> bool:
|
func _has_work_in_flight() -> bool:
|
||||||
return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty()
|
return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty() or not _pending_actions.is_empty()
|
||||||
|
|
||||||
|
|
||||||
## Construction is inert. The composition root calls activate only after its
|
## Construction is inert. The composition root calls activate only after its
|
||||||
@@ -128,8 +129,15 @@ func set_client_health_blocked(blocked: bool) -> void:
|
|||||||
|
|
||||||
func snapshot() -> Dictionary:
|
func snapshot() -> Dictionary:
|
||||||
var busy: Array[String] = []
|
var busy: Array[String] = []
|
||||||
|
var names := _action_names.duplicate(true)
|
||||||
|
var phases := _read_action_phases()
|
||||||
for client_id in _action_threads:
|
for client_id in _action_threads:
|
||||||
busy.append(String(client_id))
|
busy.append(String(client_id))
|
||||||
|
for pending in _pending_actions:
|
||||||
|
var client_id := str(pending.client_id)
|
||||||
|
busy.append(client_id)
|
||||||
|
names[client_id] = pending.action
|
||||||
|
phases[client_id] = "queued"
|
||||||
busy.sort()
|
busy.sort()
|
||||||
return {
|
return {
|
||||||
"accepting_work": _accepting_work,
|
"accepting_work": _accepting_work,
|
||||||
@@ -137,8 +145,8 @@ func snapshot() -> Dictionary:
|
|||||||
"refresh_completed": _refresh_completed_msec > 0,
|
"refresh_completed": _refresh_completed_msec > 0,
|
||||||
"post_update_repin_running": _post_update_thread != null,
|
"post_update_repin_running": _post_update_thread != null,
|
||||||
"busy_actions": busy,
|
"busy_actions": busy,
|
||||||
"action_names": _action_names.duplicate(true),
|
"action_names": names,
|
||||||
"action_phases": _read_action_phases(),
|
"action_phases": phases,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +162,7 @@ func _process(_delta: float) -> void:
|
|||||||
_poll_post_update_repin()
|
_poll_post_update_repin()
|
||||||
_poll_refresh()
|
_poll_refresh()
|
||||||
_poll_actions()
|
_poll_actions()
|
||||||
|
_start_next_action()
|
||||||
_check_refresh_timeout()
|
_check_refresh_timeout()
|
||||||
_check_action_timeouts()
|
_check_action_timeouts()
|
||||||
_retry_deferred_refresh()
|
_retry_deferred_refresh()
|
||||||
@@ -166,6 +175,7 @@ func _process(_delta: float) -> void:
|
|||||||
func quiesce(deadline_msec: int = 0) -> Dictionary:
|
func quiesce(deadline_msec: int = 0) -> Dictionary:
|
||||||
_accepting_work = false
|
_accepting_work = false
|
||||||
_refresh_state = RefreshState.SHUTTING_DOWN
|
_refresh_state = RefreshState.SHUTTING_DOWN
|
||||||
|
_pending_actions.clear()
|
||||||
_publish_snapshot()
|
_publish_snapshot()
|
||||||
for client_id in _action_threads:
|
for client_id in _action_threads:
|
||||||
_set_action_cancelled(String(client_id), true)
|
_set_action_cancelled(String(client_id), true)
|
||||||
@@ -464,7 +474,7 @@ func _start_action(client_id: String, action: String, request_id: String) -> Dic
|
|||||||
return {"ok": false, "error": "Client worker is unavailable."}
|
return {"ok": false, "error": "Client worker is unavailable."}
|
||||||
if action not in ["configure", "remove"]:
|
if action not in ["configure", "remove"]:
|
||||||
return {"ok": false, "error": "Unknown client action: %s" % action}
|
return {"ok": false, "error": "Unknown client action: %s" % action}
|
||||||
if _mutation_termination_unproven.has(client_id):
|
if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty():
|
||||||
return {
|
return {
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": MutationLock.recovery_message(),
|
"error": MutationLock.recovery_message(),
|
||||||
@@ -472,8 +482,61 @@ func _start_action(client_id: String, action: String, request_id: String) -> Dic
|
|||||||
}
|
}
|
||||||
if _action_threads.has(client_id):
|
if _action_threads.has(client_id):
|
||||||
return {"ok": false, "error": "A client action is already running for %s." % client_id}
|
return {"ok": false, "error": "A client action is already running for %s." % client_id}
|
||||||
|
for pending in _pending_actions:
|
||||||
|
if pending.client_id == client_id:
|
||||||
|
return {"ok": false, "error": "A client action is already queued for %s." % client_id}
|
||||||
if not ClientConfigurator.has_client(client_id):
|
if not ClientConfigurator.has_client(client_id):
|
||||||
return {"ok": false, "error": "Unknown client: %s" % client_id}
|
return {"ok": false, "error": "Unknown client: %s" % client_id}
|
||||||
|
if request_id.is_empty():
|
||||||
|
_pending_actions.append({"client_id": client_id, "action": action})
|
||||||
|
_start_next_action()
|
||||||
|
_publish_snapshot()
|
||||||
|
return {"ok": true}
|
||||||
|
if not _action_threads.is_empty() or not _pending_actions.is_empty():
|
||||||
|
return {"ok": false, "error": "Another client action is running or queued. Retry when it finishes."}
|
||||||
|
return _begin_action(client_id, action, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _start_next_action() -> void:
|
||||||
|
if not _accepting_work or not _action_threads.is_empty() or _pending_actions.is_empty():
|
||||||
|
return
|
||||||
|
if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty() or MutationLock.is_locked():
|
||||||
|
_discard_pending_actions(MutationLock.recovery_message())
|
||||||
|
return
|
||||||
|
var pending: Dictionary = _pending_actions.pop_front()
|
||||||
|
var started := _begin_action(str(pending.client_id), str(pending.action), "")
|
||||||
|
if not bool(started.get("ok", false)):
|
||||||
|
action_completed.emit(str(pending.client_id), str(pending.action), {
|
||||||
|
"status": "error", "message": str(started.error),
|
||||||
|
}, {})
|
||||||
|
_publish_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_pending_action(client_id: String) -> bool:
|
||||||
|
for index in range(_pending_actions.size()):
|
||||||
|
var entry := _pending_actions[index]
|
||||||
|
if str(entry.client_id) != client_id:
|
||||||
|
continue
|
||||||
|
_pending_actions.remove_at(index)
|
||||||
|
action_completed.emit(client_id, str(entry.action), {
|
||||||
|
"status": "cancelled", "message": "Client action cancelled before it started.",
|
||||||
|
}, {})
|
||||||
|
_publish_snapshot()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _discard_pending_actions(message: String) -> void:
|
||||||
|
var pending := _pending_actions.duplicate(true)
|
||||||
|
_pending_actions.clear()
|
||||||
|
for entry in pending:
|
||||||
|
action_completed.emit(str(entry.client_id), str(entry.action), {
|
||||||
|
"status": "error", "message": message,
|
||||||
|
}, {})
|
||||||
|
_publish_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_action(client_id: String, action: String, request_id: String) -> Dictionary:
|
||||||
ClientConfigurator.warm_env_snapshot()
|
ClientConfigurator.warm_env_snapshot()
|
||||||
_set_action_cancelled(client_id, false)
|
_set_action_cancelled(client_id, false)
|
||||||
_clear_action_phase(client_id)
|
_clear_action_phase(client_id)
|
||||||
@@ -631,6 +694,7 @@ func _record_unproven_action_result(
|
|||||||
return
|
return
|
||||||
if (
|
if (
|
||||||
bool(result.get("termination_failed", false))
|
bool(result.get("termination_failed", false))
|
||||||
|
or bool(result.get("mutation_lock_release_failed", false))
|
||||||
or bool(prewarm.get("termination_failed", false))
|
or bool(prewarm.get("termination_failed", false))
|
||||||
):
|
):
|
||||||
_mark_mutation_termination_unproven(client_id, action)
|
_mark_mutation_termination_unproven(client_id, action)
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ extends RefCounted
|
|||||||
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
||||||
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
||||||
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
||||||
|
const VECTOR3I_KEYS: Array[String] = ["x", "y", "z"]
|
||||||
|
const QUATERNION_KEYS: Array[String] = ["x", "y", "z", "w"]
|
||||||
|
const BASIS_KEYS: Array[String] = ["x", "y", "z"]
|
||||||
|
const RECT2_KEYS: Array[String] = ["position", "size"]
|
||||||
|
const AABB_KEYS: Array[String] = ["position", "size"]
|
||||||
|
|
||||||
|
## Vector3i components are 32-bit signed integers, and GDScript's int()
|
||||||
|
## conversion wraps (2147483648 -> -2147483648) or zeroes (1e40 -> 0)
|
||||||
|
## out-of-range floats, so the range is checked before any conversion.
|
||||||
|
const INT32_MIN := -2147483648
|
||||||
|
const INT32_MAX := 2147483647
|
||||||
|
|
||||||
|
|
||||||
static func parse_color(value: Variant) -> Variant:
|
static func parse_color(value: Variant) -> Variant:
|
||||||
@@ -104,5 +115,235 @@ static func parse_float(value: Variant) -> Variant:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func parse_vector3i(value: Variant) -> Variant:
|
||||||
|
if value is Vector3i:
|
||||||
|
return value
|
||||||
|
var components: Array = []
|
||||||
|
if value is Dictionary:
|
||||||
|
var d: Dictionary = value
|
||||||
|
if not d.has_all(VECTOR3I_KEYS):
|
||||||
|
return null
|
||||||
|
components = [d.x, d.y, d.z]
|
||||||
|
elif value is Array:
|
||||||
|
var arr: Array = value
|
||||||
|
if arr.size() != 3:
|
||||||
|
return null
|
||||||
|
components = arr
|
||||||
|
else:
|
||||||
|
return null
|
||||||
|
var parsed: Array = []
|
||||||
|
for component in components:
|
||||||
|
var number: Variant = _int32_component(component)
|
||||||
|
if number == null:
|
||||||
|
return null
|
||||||
|
parsed.append(number)
|
||||||
|
return Vector3i(parsed[0], parsed[1], parsed[2])
|
||||||
|
|
||||||
|
|
||||||
|
static func parse_quaternion(value: Variant) -> Variant:
|
||||||
|
if value is Quaternion:
|
||||||
|
return value
|
||||||
|
var components: Array = []
|
||||||
|
if value is Dictionary:
|
||||||
|
var d: Dictionary = value
|
||||||
|
if not d.has_all(QUATERNION_KEYS):
|
||||||
|
return null
|
||||||
|
components = [d.x, d.y, d.z, d.w]
|
||||||
|
elif value is Array:
|
||||||
|
var arr: Array = value
|
||||||
|
if arr.size() != 4:
|
||||||
|
return null
|
||||||
|
components = arr
|
||||||
|
else:
|
||||||
|
return null
|
||||||
|
var parsed: Array = []
|
||||||
|
for component in components:
|
||||||
|
if not _is_finite_number(component):
|
||||||
|
return null
|
||||||
|
parsed.append(float(component))
|
||||||
|
## Mathematical value only: a zero-length quaternion is returned as-is.
|
||||||
|
## The animation boundary (AnimationValues.coerce_for_type) owns the
|
||||||
|
## rotation contract — it rejects zero-length and normalizes the rest.
|
||||||
|
var quat := Quaternion(parsed[0], parsed[1], parsed[2], parsed[3])
|
||||||
|
## Components are float32: a finite double (e.g. 1e40) can still overflow
|
||||||
|
## to INF at construction, so finiteness is re-checked on the result.
|
||||||
|
if not _is_finite_quaternion(quat):
|
||||||
|
return null
|
||||||
|
return quat
|
||||||
|
|
||||||
|
|
||||||
|
## Basis: accepts the canonical serializer shape ({x:{x,y,z}, y:{…}, z:{…}})
|
||||||
|
## and a 3-element array of axis vectors. Each axis goes through
|
||||||
|
## `parse_vector3`, so the component strictness is shared.
|
||||||
|
static func parse_basis(value: Variant) -> Variant:
|
||||||
|
if value is Basis:
|
||||||
|
return value
|
||||||
|
var rows: Array = []
|
||||||
|
if value is Dictionary:
|
||||||
|
var d: Dictionary = value
|
||||||
|
if not d.has_all(BASIS_KEYS):
|
||||||
|
return null
|
||||||
|
rows = [d.x, d.y, d.z]
|
||||||
|
elif value is Array:
|
||||||
|
var arr: Array = value
|
||||||
|
if arr.size() != 3:
|
||||||
|
return null
|
||||||
|
rows = arr
|
||||||
|
else:
|
||||||
|
return null
|
||||||
|
var axes: Array = []
|
||||||
|
for row in rows:
|
||||||
|
var axis: Variant = parse_vector3(row)
|
||||||
|
if axis == null or not _is_finite_vector3(axis as Vector3):
|
||||||
|
return null
|
||||||
|
axes.append(axis)
|
||||||
|
return Basis(axes[0], axes[1], axes[2])
|
||||||
|
|
||||||
|
|
||||||
|
## Transform3D: accepts the canonical serializer shape
|
||||||
|
## ({basis:{x,y,z}, origin:{x,y,z}}) and the ergonomic
|
||||||
|
## ({position, rotation_degrees?, scale?}) shape — rotation defaults to zero
|
||||||
|
## and scale to one so a bare position is a valid translation.
|
||||||
|
static func parse_transform3d(value: Variant) -> Variant:
|
||||||
|
if value is Transform3D:
|
||||||
|
return value
|
||||||
|
if not value is Dictionary:
|
||||||
|
return null
|
||||||
|
var d: Dictionary = value
|
||||||
|
if d.has("basis") or d.has("origin"):
|
||||||
|
if not (d.has("basis") and d.has("origin")):
|
||||||
|
return null
|
||||||
|
var basis: Variant = parse_basis(d.basis)
|
||||||
|
var origin: Variant = parse_vector3(d.origin)
|
||||||
|
if basis == null or origin == null or not _is_finite_vector3(origin as Vector3):
|
||||||
|
return null
|
||||||
|
return Transform3D(basis, origin)
|
||||||
|
if d.has("position"):
|
||||||
|
var pos: Variant = parse_vector3(d.position)
|
||||||
|
if pos == null or not _is_finite_vector3(pos as Vector3):
|
||||||
|
return null
|
||||||
|
var rot_basis := Basis.IDENTITY
|
||||||
|
if d.has("rotation_degrees"):
|
||||||
|
var rot_deg: Variant = parse_vector3(d.rotation_degrees)
|
||||||
|
if rot_deg == null or not _is_finite_vector3(rot_deg as Vector3):
|
||||||
|
return null
|
||||||
|
rot_basis = Basis.from_euler((rot_deg as Vector3) * (PI / 180.0))
|
||||||
|
elif d.has("rotation"):
|
||||||
|
var rot: Variant = parse_vector3(d.rotation)
|
||||||
|
if rot == null or not _is_finite_vector3(rot as Vector3):
|
||||||
|
return null
|
||||||
|
rot_basis = Basis.from_euler(rot)
|
||||||
|
if d.has("scale"):
|
||||||
|
var scale: Variant = parse_vector3(d.scale)
|
||||||
|
if scale == null or not _is_finite_vector3(scale as Vector3):
|
||||||
|
return null
|
||||||
|
## Local axes: the ergonomic shape describes a node transform, and
|
||||||
|
## `scaled()` would apply the scale in global axes (skewing the
|
||||||
|
## rotation for a non-uniform scale).
|
||||||
|
rot_basis = rot_basis.scaled_local(scale)
|
||||||
|
return Transform3D(rot_basis, pos)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func parse_rect2(value: Variant) -> Variant:
|
||||||
|
if value is Rect2:
|
||||||
|
return value
|
||||||
|
if value is Dictionary:
|
||||||
|
var d: Dictionary = value
|
||||||
|
if not d.has_all(RECT2_KEYS):
|
||||||
|
return null
|
||||||
|
var pos: Variant = parse_vector2(d.position)
|
||||||
|
var size: Variant = parse_vector2(d.size)
|
||||||
|
if pos == null or size == null:
|
||||||
|
return null
|
||||||
|
if not (_is_finite_vector2(pos as Vector2) and _is_finite_vector2(size as Vector2)):
|
||||||
|
return null
|
||||||
|
return Rect2(pos, size)
|
||||||
|
if value is Array:
|
||||||
|
var arr: Array = value
|
||||||
|
if arr.size() != 4:
|
||||||
|
return null
|
||||||
|
for item in arr:
|
||||||
|
if not _is_number(item):
|
||||||
|
return null
|
||||||
|
var rect := Rect2(float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]))
|
||||||
|
if not (_is_finite_vector2(rect.position) and _is_finite_vector2(rect.size)):
|
||||||
|
return null
|
||||||
|
return rect
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func parse_aabb(value: Variant) -> Variant:
|
||||||
|
if value is AABB:
|
||||||
|
return value
|
||||||
|
if value is Dictionary:
|
||||||
|
var d: Dictionary = value
|
||||||
|
if not d.has_all(AABB_KEYS):
|
||||||
|
return null
|
||||||
|
var pos: Variant = parse_vector3(d.position)
|
||||||
|
var size: Variant = parse_vector3(d.size)
|
||||||
|
if pos == null or size == null:
|
||||||
|
return null
|
||||||
|
if not (_is_finite_vector3(pos as Vector3) and _is_finite_vector3(size as Vector3)):
|
||||||
|
return null
|
||||||
|
return AABB(pos, size)
|
||||||
|
if value is Array:
|
||||||
|
var arr: Array = value
|
||||||
|
if arr.size() != 6:
|
||||||
|
return null
|
||||||
|
for item in arr:
|
||||||
|
if not _is_number(item):
|
||||||
|
return null
|
||||||
|
var pos := Vector3(float(arr[0]), float(arr[1]), float(arr[2]))
|
||||||
|
var size := Vector3(float(arr[3]), float(arr[4]), float(arr[5]))
|
||||||
|
if not (_is_finite_vector3(pos) and _is_finite_vector3(size)):
|
||||||
|
return null
|
||||||
|
return AABB(pos, size)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func parse_node_path(value: Variant) -> Variant:
|
||||||
|
if value is NodePath:
|
||||||
|
return value
|
||||||
|
if value is String:
|
||||||
|
return NodePath(value)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func parse_string_name(value: Variant) -> Variant:
|
||||||
|
if value is StringName:
|
||||||
|
return value
|
||||||
|
if value is String:
|
||||||
|
return StringName(value)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
static func _is_number(v: Variant) -> bool:
|
static func _is_number(v: Variant) -> bool:
|
||||||
return v is int or v is float
|
return v is int or v is float
|
||||||
|
|
||||||
|
|
||||||
|
## One Vector3i component: finite and within int32 range, else null.
|
||||||
|
## In-range fractional values keep the existing truncation policy.
|
||||||
|
static func _int32_component(v: Variant) -> Variant:
|
||||||
|
if not _is_number(v):
|
||||||
|
return null
|
||||||
|
var number := float(v)
|
||||||
|
if not is_finite(number) or number < float(INT32_MIN) or number > float(INT32_MAX):
|
||||||
|
return null
|
||||||
|
return int(number)
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_finite_number(v: Variant) -> bool:
|
||||||
|
return _is_number(v) and is_finite(float(v))
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_finite_vector2(v: Vector2) -> bool:
|
||||||
|
return is_finite(v.x) and is_finite(v.y)
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_finite_vector3(v: Vector3) -> bool:
|
||||||
|
return is_finite(v.x) and is_finite(v.y) and is_finite(v.z)
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_finite_quaternion(v: Quaternion) -> bool:
|
||||||
|
return is_finite(v.x) and is_finite(v.y) and is_finite(v.z) and is_finite(v.w)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const PORT_EXCLUDED := 7
|
|||||||
const FOREIGN_PORT := 8
|
const FOREIGN_PORT := 8
|
||||||
const STOPPING := 10
|
const STOPPING := 10
|
||||||
const STOPPED := 11
|
const STOPPED := 11
|
||||||
|
const UNSUPPORTED_CONFIG := 12
|
||||||
|
|
||||||
const _NAMES := {
|
const _NAMES := {
|
||||||
UNINITIALIZED: "uninitialized",
|
UNINITIALIZED: "uninitialized",
|
||||||
@@ -26,6 +27,7 @@ const _NAMES := {
|
|||||||
FOREIGN_PORT: "foreign_port",
|
FOREIGN_PORT: "foreign_port",
|
||||||
STOPPING: "stopping",
|
STOPPING: "stopping",
|
||||||
STOPPED: "stopped",
|
STOPPED: "stopped",
|
||||||
|
UNSUPPORTED_CONFIG: "unsupported_config",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -45,13 +47,14 @@ static func is_terminal_diagnosis(state: int) -> bool:
|
|||||||
or state == PORT_EXCLUDED
|
or state == PORT_EXCLUDED
|
||||||
or state == INCOMPATIBLE
|
or state == INCOMPATIBLE
|
||||||
or state == FOREIGN_PORT
|
or state == FOREIGN_PORT
|
||||||
|
or state == UNSUPPORTED_CONFIG
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
## True when the dock should skip interpreting client health (incompatible
|
## True when the dock should skip interpreting client health (incompatible
|
||||||
## tool surface). This must NOT block Configure writes — those take an
|
## tool surface). This must NOT block Configure writes — those take an
|
||||||
## explicit url and the live plugin version (#916). Currently just
|
## explicit url and the live plugin version (#916). Currently just
|
||||||
## INCOMPATIBLE — FOREIGN_PORT is transitional and may resolve to READY
|
## INCOMPATIBLE and UNSUPPORTED_CONFIG — FOREIGN_PORT may resolve to READY
|
||||||
## if the foreign occupant turns out to speak our handshake.
|
## if the foreign occupant turns out to speak our handshake.
|
||||||
static func blocks_client_health(state: int) -> bool:
|
static func blocks_client_health(state: int) -> bool:
|
||||||
return state == INCOMPATIBLE
|
return state in [INCOMPATIBLE, UNSUPPORTED_CONFIG]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
const MAX_TRIANGLES := 2048
|
||||||
|
const MAX_VERTICES := 6144
|
||||||
|
const MAX_SURFACES := 32
|
||||||
|
|
||||||
|
|
||||||
|
static func estimate(mesh: Mesh) -> Dictionary:
|
||||||
|
if mesh.get_script() != null:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Scripted meshes cannot be bounded before geometry extraction; use an ArrayMesh without a script")
|
||||||
|
var triangles := 0
|
||||||
|
var vertices := 0
|
||||||
|
if mesh is ArrayMesh:
|
||||||
|
var array_mesh := mesh as ArrayMesh
|
||||||
|
var surface_count := array_mesh.get_surface_count()
|
||||||
|
if surface_count > MAX_SURFACES:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Geometry extraction supports at most %d mesh surfaces; found %d" % [MAX_SURFACES, surface_count])
|
||||||
|
for surface in surface_count:
|
||||||
|
var vertex_count := array_mesh.surface_get_array_len(surface)
|
||||||
|
vertices += vertex_count
|
||||||
|
if array_mesh.surface_get_primitive_type(surface) != Mesh.PRIMITIVE_TRIANGLES:
|
||||||
|
continue
|
||||||
|
var index_count := array_mesh.surface_get_array_index_len(surface)
|
||||||
|
triangles += int((index_count if index_count > 0 else vertex_count) / 3)
|
||||||
|
return {"triangles": triangles, "vertices": vertices}
|
||||||
|
# PrimitiveMesh surface counters can generate the entire mesh on first use.
|
||||||
|
if mesh is BoxMesh:
|
||||||
|
var box := mesh as BoxMesh
|
||||||
|
var x := mini(box.subdivide_width, MAX_TRIANGLES) + 1
|
||||||
|
var y := mini(box.subdivide_height, MAX_TRIANGLES) + 1
|
||||||
|
var z := mini(box.subdivide_depth, MAX_TRIANGLES) + 1
|
||||||
|
triangles = 4 * (x * y + x * z + y * z)
|
||||||
|
elif mesh is PlaneMesh:
|
||||||
|
var plane := mesh as PlaneMesh
|
||||||
|
triangles = 2 * (mini(plane.subdivide_width, MAX_TRIANGLES) + 1) * (mini(plane.subdivide_depth, MAX_TRIANGLES) + 1)
|
||||||
|
elif mesh is SphereMesh or mesh is CylinderMesh or mesh is CapsuleMesh:
|
||||||
|
var radial := mini(int(mesh.get("radial_segments")), MAX_TRIANGLES)
|
||||||
|
var rings := mini(int(mesh.get("rings")), MAX_TRIANGLES)
|
||||||
|
triangles = 2 * radial * (rings + 2)
|
||||||
|
if mesh is CapsuleMesh:
|
||||||
|
triangles *= 3
|
||||||
|
elif mesh is PointMesh:
|
||||||
|
return {"triangles": 0, "vertices": 1}
|
||||||
|
else:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
|
"Mesh class %s has no bounded geometry preflight; use ArrayMesh, BoxMesh, PlaneMesh, SphereMesh, CylinderMesh or CapsuleMesh" % mesh.get_class())
|
||||||
|
return {"triangles": triangles, "vertices": triangles * 3}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c7rw0m5m0i1x5
|
||||||
@@ -16,6 +16,8 @@ const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json"
|
|||||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
||||||
static var _process_spawn_mutex := Mutex.new()
|
static var _process_spawn_mutex := Mutex.new()
|
||||||
|
|
||||||
|
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
|
||||||
|
|
||||||
|
|
||||||
## Serialize plugin-owned subprocess creation across the brief process-global
|
## Serialize plugin-owned subprocess creation across the brief process-global
|
||||||
## environment override used for server capabilities. Callers must keep this
|
## environment override used for server capabilities. Callers must keep this
|
||||||
@@ -51,18 +53,11 @@ static func can_bind_local_port(port: int) -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
## True when `port` is bound on 127.0.0.1. Probes via TCPServer first,
|
## Windows permits a loopback bind beside a wildcard listener. Use the OS
|
||||||
## falls back to OS scraping. Callers that want per-scraper trace
|
## listener table, and treat failed queries as unavailable rather than free.
|
||||||
## counters should call `is_port_in_use_via_scrape` with a trace hook
|
|
||||||
## after their own `can_bind_local_port` probe.
|
|
||||||
static func is_port_in_use(port: int) -> bool:
|
static func is_port_in_use(port: int) -> bool:
|
||||||
if can_bind_local_port(port):
|
if OS.get_name() == "Windows":
|
||||||
## On POSIX, an IPv6 wildcard listener can coexist with a
|
return windows_port_occupancy(port) != PortOccupancy.FREE
|
||||||
## successful 127.0.0.1 bind probe. Confirm with lsof so startup
|
|
||||||
## sees the same listener set that shutdown/recovery would see.
|
|
||||||
if OS.get_name() != "Windows":
|
|
||||||
return is_port_in_use_via_scrape(port)
|
|
||||||
return false
|
|
||||||
return is_port_in_use_via_scrape(port)
|
return is_port_in_use_via_scrape(port)
|
||||||
|
|
||||||
|
|
||||||
@@ -73,21 +68,7 @@ static func is_port_in_use(port: int) -> bool:
|
|||||||
static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool:
|
static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool:
|
||||||
var output: Array = []
|
var output: Array = []
|
||||||
if OS.get_name() == "Windows":
|
if OS.get_name() == "Windows":
|
||||||
_trace(trace, "netstat")
|
return windows_port_occupancy(port, windows_listener_snapshot(trace)) != PortOccupancy.FREE
|
||||||
var exit_code := OS.execute("netstat", ["-ano"], output, true)
|
|
||||||
if exit_code == 0 and output.size() > 0:
|
|
||||||
var stdout := str(output[0])
|
|
||||||
if parse_windows_netstat_listening(stdout, port):
|
|
||||||
return true
|
|
||||||
## A healthy dump with no listener row IS the answer — don't
|
|
||||||
## pay the ~1.2s powershell.exe spawn to confirm "not in use"
|
|
||||||
## (see find_all_pids_on_port for the cost rationale).
|
|
||||||
if windows_netstat_dump_parseable(stdout):
|
|
||||||
return false
|
|
||||||
## Fallback: netstat can be absent or unparseable on
|
|
||||||
## stripped/locale-odd Windows installs.
|
|
||||||
_trace(trace, "powershell")
|
|
||||||
return not find_listener_pids_windows(port).is_empty()
|
|
||||||
_trace(trace, "lsof")
|
_trace(trace, "lsof")
|
||||||
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
||||||
if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty():
|
if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty():
|
||||||
@@ -102,8 +83,89 @@ static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## One snapshot per selection operation avoids a subprocess for every candidate.
|
||||||
|
## Unknown observations never prove a port free. This is not ownership evidence.
|
||||||
|
static func windows_listener_snapshot(trace: Callable = Callable()) -> Dictionary:
|
||||||
|
var output: Array = []
|
||||||
|
_trace(trace, "netstat")
|
||||||
|
var code := OS.execute("netstat", ["-ano"], output, true)
|
||||||
|
var snapshot := windows_snapshot_from_netstat(code, output)
|
||||||
|
if snapshot.known:
|
||||||
|
return snapshot
|
||||||
|
_trace(trace, "powershell")
|
||||||
|
output.clear()
|
||||||
|
var script := "$ErrorActionPreference='Stop'; try { $listeners = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Listen' } | Select-Object LocalPort,OwningProcess); ConvertTo-Json -Compress -Depth 3 -InputObject @{ listeners = $listeners }; exit 0 } catch { exit 1 }"
|
||||||
|
code = execute_windows_powershell(script, output)
|
||||||
|
return windows_snapshot_from_powershell(code, output)
|
||||||
|
|
||||||
|
|
||||||
|
static func windows_port_occupancy(port: int, snapshot: Dictionary = {}) -> PortOccupancy:
|
||||||
|
if snapshot.is_empty():
|
||||||
|
snapshot = windows_listener_snapshot()
|
||||||
|
if not snapshot.known:
|
||||||
|
return PortOccupancy.UNKNOWN
|
||||||
|
return PortOccupancy.OCCUPIED if snapshot.listeners.has(port) else PortOccupancy.FREE
|
||||||
|
|
||||||
|
|
||||||
|
static func windows_snapshot_from_netstat(exit_code: int, output: Array) -> Dictionary:
|
||||||
|
var unknown := {"known": false, "listeners": {}}
|
||||||
|
if exit_code != 0 or output.is_empty():
|
||||||
|
return unknown
|
||||||
|
var listeners := {}
|
||||||
|
var saw_tcp := false
|
||||||
|
for line in str(output[0]).split("\n"):
|
||||||
|
var fields := split_on_whitespace(line.strip_edges())
|
||||||
|
if fields.is_empty() or fields[0].to_upper() != "TCP":
|
||||||
|
continue
|
||||||
|
if fields.size() < 5 or not fields[1].contains(":") or not fields[2].contains(":"):
|
||||||
|
return unknown
|
||||||
|
var port_text := fields[1].get_slice(":", fields[1].get_slice_count(":") - 1)
|
||||||
|
var remote_port_text := fields[2].get_slice(":", fields[2].get_slice_count(":") - 1)
|
||||||
|
var pid_text := fields[fields.size() - 1]
|
||||||
|
if not port_text.is_valid_int() or not remote_port_text.is_valid_int() or not pid_text.is_valid_int():
|
||||||
|
return unknown
|
||||||
|
var port := int(port_text)
|
||||||
|
var pid := int(pid_text)
|
||||||
|
if port < 1 or port > 65535 or pid < 0 or pid > 4294967295 or int(remote_port_text) < 0 or int(remote_port_text) > 65535:
|
||||||
|
return unknown
|
||||||
|
saw_tcp = true
|
||||||
|
## Windows localizes the state column, but listeners keep a wildcard foreign port.
|
||||||
|
if fields[2].ends_with(":0"):
|
||||||
|
_record_windows_listener(listeners, port, pid)
|
||||||
|
return {"known": true, "listeners": listeners} if saw_tcp else unknown
|
||||||
|
|
||||||
|
|
||||||
|
static func windows_snapshot_from_powershell(exit_code: int, output: Array) -> Dictionary:
|
||||||
|
var unknown := {"known": false, "listeners": {}}
|
||||||
|
if exit_code != 0 or output.is_empty():
|
||||||
|
return unknown
|
||||||
|
var parsed: Variant = JSON.parse_string(str(output[0]))
|
||||||
|
if not (parsed is Dictionary) or not (parsed.get("listeners") is Array):
|
||||||
|
return unknown
|
||||||
|
var listeners := {}
|
||||||
|
for row in parsed.listeners:
|
||||||
|
if not (row is Dictionary):
|
||||||
|
return unknown
|
||||||
|
var port: Variant = row.get("LocalPort")
|
||||||
|
var pid: Variant = row.get("OwningProcess")
|
||||||
|
if not (port is int or port is float) or not is_finite(float(port)) or float(port) != floor(float(port)) or port < 1 or port > 65535:
|
||||||
|
return unknown
|
||||||
|
if not (pid is int or pid is float) or not is_finite(float(pid)) or float(pid) != floor(float(pid)) or pid < 0 or pid > 4294967295:
|
||||||
|
return unknown
|
||||||
|
_record_windows_listener(listeners, int(port), int(pid))
|
||||||
|
return {"known": true, "listeners": listeners}
|
||||||
|
|
||||||
|
|
||||||
|
static func _record_windows_listener(listeners: Dictionary, port: int, pid: int) -> void:
|
||||||
|
if not listeners.has(port):
|
||||||
|
listeners[port] = []
|
||||||
|
## PID 0 still occupies the port, but cannot identify a process we may stop.
|
||||||
|
if pid > 0 and not listeners[port].has(pid):
|
||||||
|
listeners[port].append(pid)
|
||||||
|
|
||||||
|
|
||||||
## Return the PID currently listening on the given TCP port, or 0 if
|
## Return the PID currently listening on the given TCP port, or 0 if
|
||||||
## the port is free. Thin convenience wrapper around `find_all_pids_on_port`
|
## no positive listener PID could be observed. Thin wrapper around `find_all_pids_on_port`
|
||||||
## — the per-OS scraping logic lives in one place.
|
## — the per-OS scraping logic lives in one place.
|
||||||
static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
|
static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
|
||||||
var pids := find_all_pids_on_port(port, trace)
|
var pids := find_all_pids_on_port(port, trace)
|
||||||
@@ -119,28 +181,14 @@ static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
|
|||||||
## can keep its cold-start trace accurate. The Windows path may fall
|
## can keep its cold-start trace accurate. The Windows path may fall
|
||||||
## through netstat → PowerShell, and a wrapping caller can't see which
|
## through netstat → PowerShell, and a wrapping caller can't see which
|
||||||
## scraper actually ran without the hook.
|
## scraper actually ran without the hook.
|
||||||
static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]:
|
static func find_all_pids_on_port(port: int, trace: Callable = Callable(), snapshot: Dictionary = {}) -> Array[int]:
|
||||||
if OS.get_name() == "Windows":
|
if OS.get_name() == "Windows":
|
||||||
var output: Array = []
|
if snapshot.is_empty():
|
||||||
_trace(trace, "netstat")
|
snapshot = windows_listener_snapshot(trace)
|
||||||
var exit_code := OS.execute("netstat", ["-ano"], output, true)
|
var pids: Array[int] = []
|
||||||
if exit_code == 0 and not output.is_empty():
|
if snapshot.known:
|
||||||
var stdout := str(output[0])
|
pids.assign(snapshot.listeners.get(port, []))
|
||||||
var netstat_pids := parse_windows_netstat_pids(stdout, port)
|
return pids
|
||||||
if not netstat_pids.is_empty():
|
|
||||||
return netstat_pids
|
|
||||||
## An empty per-port parse from a healthy dump IS the answer
|
|
||||||
## ("no listener"). Confirming it through the PowerShell probe
|
|
||||||
## costs a powershell.exe spawn (~1.2s measured) against ~30ms
|
|
||||||
## for the netstat scrape — two such confirmations dominated a
|
|
||||||
## ~7s Windows startup walk. Only fall through when the dump
|
|
||||||
## itself is unusable (netstat absent, or so format-odd that
|
|
||||||
## zero TCP rows parse).
|
|
||||||
if windows_netstat_dump_parseable(stdout):
|
|
||||||
var no_listeners: Array[int] = []
|
|
||||||
return no_listeners
|
|
||||||
_trace(trace, "powershell")
|
|
||||||
return find_listener_pids_windows(port)
|
|
||||||
var output: Array = []
|
var output: Array = []
|
||||||
_trace(trace, "lsof")
|
_trace(trace, "lsof")
|
||||||
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true)
|
||||||
@@ -164,17 +212,6 @@ static func _trace(trace: Callable, counter: String) -> void:
|
|||||||
trace.call(counter)
|
trace.call(counter)
|
||||||
|
|
||||||
|
|
||||||
static func find_listener_pids_windows(port: int) -> Array[int]:
|
|
||||||
var script := (
|
|
||||||
"Get-NetTCPConnection -LocalPort %d -State Listen "
|
|
||||||
+ "-ErrorAction SilentlyContinue | "
|
|
||||||
+ "Select-Object -ExpandProperty OwningProcess"
|
|
||||||
) % port
|
|
||||||
var output: Array = []
|
|
||||||
var exit_code := execute_windows_powershell(script, output)
|
|
||||||
return windows_listener_pids_from_execute_result(exit_code, output)
|
|
||||||
|
|
||||||
|
|
||||||
static func execute_windows_powershell(script: String, output: Array) -> int:
|
static func execute_windows_powershell(script: String, output: Array) -> int:
|
||||||
var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
|
var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
|
||||||
for exe in windows_powershell_candidates():
|
for exe in windows_powershell_candidates():
|
||||||
@@ -205,17 +242,6 @@ static func windows_powershell_candidates() -> Array[String]:
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]:
|
|
||||||
var empty: Array[int] = []
|
|
||||||
if exit_code == 0 and not output.is_empty():
|
|
||||||
return parse_pid_lines(str(output[0]))
|
|
||||||
return empty
|
|
||||||
|
|
||||||
|
|
||||||
static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool:
|
|
||||||
return not windows_listener_pids_from_execute_result(exit_code, output).is_empty()
|
|
||||||
|
|
||||||
|
|
||||||
## Pure parser for `lsof -ti` output — newline-separated decimal PIDs.
|
## Pure parser for `lsof -ti` output — newline-separated decimal PIDs.
|
||||||
## Empty lines, non-positive values, non-numeric tokens, and duplicate
|
## Empty lines, non-positive values, non-numeric tokens, and duplicate
|
||||||
## IPv4/IPv6 rows for the same listener are dropped.
|
## IPv4/IPv6 rows for the same listener are dropped.
|
||||||
@@ -257,7 +283,7 @@ static func parse_pid_lines(raw: String) -> Array[int]:
|
|||||||
|
|
||||||
|
|
||||||
## Parse a Windows `netstat -ano` dump and return PIDs of rows whose
|
## Parse a Windows `netstat -ano` dump and return PIDs of rows whose
|
||||||
## local address ends with `:port` AND state is `LISTENING`. Substring
|
## local address ends with `:port` and foreign port is zero. Substring
|
||||||
## matching the whole dump is wrong: a remote address containing
|
## matching the whole dump is wrong: a remote address containing
|
||||||
## `:port` would false-positive against an unrelated ESTABLISHED row.
|
## `:port` would false-positive against an unrelated ESTABLISHED row.
|
||||||
static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
|
static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
|
||||||
@@ -266,54 +292,15 @@ static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]:
|
static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]:
|
||||||
|
var snapshot := windows_snapshot_from_netstat(0, [stdout])
|
||||||
var pids: Array[int] = []
|
var pids: Array[int] = []
|
||||||
var port_suffix := ":%d" % port
|
if snapshot.known:
|
||||||
for line in stdout.split("\n"):
|
pids.assign(snapshot.listeners.get(port, []))
|
||||||
var s := line.strip_edges()
|
|
||||||
if s.is_empty():
|
|
||||||
continue
|
|
||||||
var fields := split_on_whitespace(s)
|
|
||||||
if fields.size() < 5: # proto, local, remote, state, pid
|
|
||||||
continue
|
|
||||||
## Locale-independent listener signal (mirrors script/_dev_env.py):
|
|
||||||
## the state column is localized ("LISTENING"/"ABHÖREN"/"ÉCOUTE"...),
|
|
||||||
## but a listener's FOREIGN address is always the wildcard ":0".
|
|
||||||
if not fields[2].ends_with(":0"):
|
|
||||||
continue
|
|
||||||
if not fields[1].ends_with(port_suffix):
|
|
||||||
continue
|
|
||||||
var pid_str := fields[fields.size() - 1]
|
|
||||||
if pid_str.is_valid_int():
|
|
||||||
var pid := int(pid_str)
|
|
||||||
if pid > 0 and not pids.has(pid):
|
|
||||||
pids.append(pid)
|
|
||||||
return pids
|
return pids
|
||||||
|
|
||||||
|
|
||||||
static func parse_windows_netstat_listening(stdout: String, port: int) -> bool:
|
|
||||||
return parse_windows_netstat_pid(stdout, port) > 0
|
|
||||||
|
|
||||||
|
|
||||||
## True when `stdout` looks like a healthy `netstat -ano` dump: at least
|
|
||||||
## one row parses as a TCP connection (proto column literally "TCP", an
|
|
||||||
## address containing ":", an integer PID in the last column). Locale-
|
|
||||||
## independent — protocol names are never localized, unlike the state
|
|
||||||
## column. Gates whether an empty per-port parse can be trusted as "no
|
|
||||||
## listener": a live Windows host always carries TCP rows (svchost/RPC
|
|
||||||
## listen on 135 at minimum), so a dump with zero parseable rows means
|
|
||||||
## netstat itself is absent/broken and the PowerShell fallback must run.
|
|
||||||
static func windows_netstat_dump_parseable(stdout: String) -> bool:
|
static func windows_netstat_dump_parseable(stdout: String) -> bool:
|
||||||
for line in stdout.split("\n"):
|
return bool(windows_snapshot_from_netstat(0, [stdout]).known)
|
||||||
var fields := split_on_whitespace(line.strip_edges())
|
|
||||||
if fields.size() < 5:
|
|
||||||
continue
|
|
||||||
if fields[0].to_upper() != "TCP":
|
|
||||||
continue
|
|
||||||
if fields[1].find(":") < 0:
|
|
||||||
continue
|
|
||||||
if fields[fields.size() - 1].is_valid_int():
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
## `String.split(" ", false)` only splits on single spaces; netstat
|
## `String.split(" ", false)` only splits on single spaces; netstat
|
||||||
@@ -727,13 +714,17 @@ static func kill_exact_processes(
|
|||||||
|
|
||||||
## Poll until the given port is no longer bound, or the timeout elapses.
|
## Poll until the given port is no longer bound, or the timeout elapses.
|
||||||
## Used after `OS.kill` so we don't race the port-in-use check on rebind.
|
## Used after `OS.kill` so we don't race the port-in-use check on rebind.
|
||||||
static func wait_for_port_free(port: int, timeout_s: float) -> void:
|
static func wait_for_port_free(port: int, timeout_s: float) -> PortOccupancy:
|
||||||
var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0)
|
var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0)
|
||||||
while is_port_in_use(port):
|
while true:
|
||||||
|
var occupancy := windows_port_occupancy(port) if OS.get_name() == "Windows" else (PortOccupancy.OCCUPIED if is_port_in_use(port) else PortOccupancy.FREE)
|
||||||
|
if occupancy != PortOccupancy.OCCUPIED:
|
||||||
|
return occupancy
|
||||||
if Time.get_ticks_msec() >= deadline:
|
if Time.get_ticks_msec() >= deadline:
|
||||||
push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s])
|
push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s])
|
||||||
return
|
return occupancy
|
||||||
OS.delay_msec(100)
|
OS.delay_msec(100)
|
||||||
|
return PortOccupancy.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
## Choose a non-Windows-reserved WS port. Returns `configured` when free;
|
## Choose a non-Windows-reserved WS port. Returns `configured` when free;
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Per-call, native-only resource graphs. Limits bound output and traversal,
|
||||||
|
## not time spent inside native engine getters.
|
||||||
|
const Serializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
|
||||||
|
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||||
|
const FAMILIES: Array[String] = [
|
||||||
|
"Shape2D", "Shape3D", "Mesh", "Material", "PhysicsMaterial", "StyleBox",
|
||||||
|
"Gradient", "Curve", "Curve2D", "Curve3D", "GradientTexture1D",
|
||||||
|
"GradientTexture2D", "CurveTexture", "CurveXYZTexture",
|
||||||
|
]
|
||||||
|
const MAX_RESOURCES := 32
|
||||||
|
const MAX_PROPERTIES := 64
|
||||||
|
const MAX_ENTRIES := 64
|
||||||
|
const MAX_VALUES := 512
|
||||||
|
const MAX_DEPTH := 3
|
||||||
|
const MAX_CONTAINER_DEPTH := 8
|
||||||
|
const MAX_STRING_BYTES := 1024
|
||||||
|
const MAX_STRING_CHARS := 256
|
||||||
|
const MAX_TRUNCATIONS := 32
|
||||||
|
const MAX_RESULT_BYTES := 65536
|
||||||
|
const BUILD_BYTES := 49152
|
||||||
|
|
||||||
|
var _depth: int
|
||||||
|
var _resources: Array[Dictionary] = []
|
||||||
|
var _references: Dictionary = {}
|
||||||
|
var _pending: Array[Dictionary] = []
|
||||||
|
var _truncations: Array[Dictionary] = []
|
||||||
|
var _values := 0
|
||||||
|
var _bytes_left := BUILD_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
static func supported(resource: Resource) -> bool:
|
||||||
|
if resource.get_script() != null:
|
||||||
|
return false
|
||||||
|
for family in FAMILIES:
|
||||||
|
if resource.is_class(family):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func inspect(resource: Resource, depth: int) -> Dictionary:
|
||||||
|
_depth = depth
|
||||||
|
var root := _resource(resource, 0, "root")
|
||||||
|
var index := 0
|
||||||
|
while index < _pending.size():
|
||||||
|
var item := _pending[index]
|
||||||
|
_expand(item.resource, item.level, item.reference, item.row)
|
||||||
|
index += 1
|
||||||
|
var graph := {"root": root, "resources": _resources, "truncations": _truncations}
|
||||||
|
var result := {"data": graph}
|
||||||
|
if JSON.stringify(result).to_utf8_buffer().size() > MAX_RESULT_BYTES:
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Resource inspection exceeded the 65536-byte result limit; inspect at a lower depth")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _omit(location: String, reason: String) -> Dictionary:
|
||||||
|
if _truncations.size() < MAX_TRUNCATIONS:
|
||||||
|
_truncations.append({"location": location.substr(0, 96), "reason": reason})
|
||||||
|
else:
|
||||||
|
_truncations[MAX_TRUNCATIONS - 1] = {"location": "root", "reason": "truncation_limit"}
|
||||||
|
return {"omitted": reason}
|
||||||
|
|
||||||
|
|
||||||
|
func _charge(bytes: int) -> bool:
|
||||||
|
if bytes > _bytes_left:
|
||||||
|
return false
|
||||||
|
_bytes_left -= bytes
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _string(value: String, location: String) -> String:
|
||||||
|
var bounded := value.substr(0, MAX_STRING_CHARS)
|
||||||
|
while JSON.stringify(bounded).to_utf8_buffer().size() > MAX_STRING_BYTES:
|
||||||
|
bounded = bounded.substr(0, bounded.length() - 1)
|
||||||
|
if bounded.length() < value.length():
|
||||||
|
_omit(location, "string_bytes")
|
||||||
|
return bounded
|
||||||
|
|
||||||
|
|
||||||
|
func _resource(resource: Resource, level: int, location: String) -> Dictionary:
|
||||||
|
var identity := resource.get_instance_id()
|
||||||
|
if _references.has(identity):
|
||||||
|
return {"ref": _references[identity]}
|
||||||
|
if _resources.size() >= MAX_RESOURCES:
|
||||||
|
return _omit(location, "resources")
|
||||||
|
var bounded_path := _string(resource.resource_path, location)
|
||||||
|
if not _charge(128 + resource.get_class().length() * 6 + JSON.stringify(bounded_path).to_utf8_buffer().size()):
|
||||||
|
return _omit(location, "result_bytes")
|
||||||
|
var reference := "r%d" % (_resources.size() + 1)
|
||||||
|
_references[identity] = reference
|
||||||
|
var row := {
|
||||||
|
"id": reference, "type": resource.get_class(),
|
||||||
|
"path": bounded_path, "properties": {},
|
||||||
|
}
|
||||||
|
_resources.append(row)
|
||||||
|
_pending.append({"resource": resource, "level": level, "reference": reference, "row": row})
|
||||||
|
return {"ref": reference}
|
||||||
|
|
||||||
|
|
||||||
|
func _expand(resource: Resource, level: int, reference: String, row: Dictionary) -> void:
|
||||||
|
if resource.get_script() != null:
|
||||||
|
row["omitted"] = _omit(reference, "scripted_resource").omitted
|
||||||
|
return
|
||||||
|
if not supported(resource):
|
||||||
|
row["omitted"] = _omit(reference, "unsupported_resource").omitted
|
||||||
|
return
|
||||||
|
if level > _depth:
|
||||||
|
row["omitted"] = _omit(reference, "depth").omitted
|
||||||
|
return
|
||||||
|
var count := 0
|
||||||
|
for property in ClassDB.class_get_property_list(resource.get_class()):
|
||||||
|
var name: String = property.name
|
||||||
|
if name == "script" or not (int(property.usage) & PROPERTY_USAGE_EDITOR) or int(property.type) == TYPE_NIL:
|
||||||
|
continue
|
||||||
|
if count >= MAX_PROPERTIES or _values >= MAX_VALUES:
|
||||||
|
_omit(reference, "properties" if count >= MAX_PROPERTIES else "values")
|
||||||
|
break
|
||||||
|
if not _charge(name.length() * 6 + 64):
|
||||||
|
_omit(reference, "result_bytes")
|
||||||
|
break
|
||||||
|
count += 1
|
||||||
|
if int(property.type) in [TYPE_CALLABLE, TYPE_SIGNAL, TYPE_RID]:
|
||||||
|
row.properties[name] = _omit(reference + "." + name, "unsupported_value")
|
||||||
|
continue
|
||||||
|
row.properties[name] = _value(ClassDB.class_get_property(resource, name), level, 0, reference + "." + name)
|
||||||
|
|
||||||
|
|
||||||
|
func _value(value: Variant, level: int, nesting: int, location: String) -> Variant:
|
||||||
|
if _values >= MAX_VALUES:
|
||||||
|
return _omit(location, "values")
|
||||||
|
_values += 1
|
||||||
|
if not _charge(64):
|
||||||
|
return _omit(location, "result_bytes")
|
||||||
|
match typeof(value):
|
||||||
|
TYPE_OBJECT:
|
||||||
|
if value == null:
|
||||||
|
return null
|
||||||
|
if value is Resource:
|
||||||
|
return _resource(value, level + 1, location)
|
||||||
|
return _omit(location, "unsupported_object")
|
||||||
|
TYPE_STRING, TYPE_STRING_NAME, TYPE_NODE_PATH:
|
||||||
|
var bounded := _string(str(value), location)
|
||||||
|
if not _charge(JSON.stringify(bounded).to_utf8_buffer().size()):
|
||||||
|
return _omit(location, "result_bytes")
|
||||||
|
return bounded
|
||||||
|
TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY:
|
||||||
|
if nesting >= MAX_CONTAINER_DEPTH:
|
||||||
|
return _omit(location, "container_depth")
|
||||||
|
var result: Array = []
|
||||||
|
for index in mini(value.size(), MAX_ENTRIES):
|
||||||
|
if _values >= MAX_VALUES or _bytes_left < 64:
|
||||||
|
_omit(location, "values" if _values >= MAX_VALUES else "result_bytes")
|
||||||
|
break
|
||||||
|
result.append(_value(value[index], level, nesting + 1, location + "[%d]" % index))
|
||||||
|
if value.size() > MAX_ENTRIES:
|
||||||
|
_omit(location, "collection_entries")
|
||||||
|
return result
|
||||||
|
TYPE_DICTIONARY:
|
||||||
|
if nesting >= MAX_CONTAINER_DEPTH:
|
||||||
|
return _omit(location, "container_depth")
|
||||||
|
var entries: Array = []
|
||||||
|
var examined := 0
|
||||||
|
for key in value:
|
||||||
|
if examined >= MAX_ENTRIES or _values >= MAX_VALUES or _bytes_left < 128:
|
||||||
|
_omit(location, "collection_entries" if examined >= MAX_ENTRIES else "values" if _values >= MAX_VALUES else "result_bytes")
|
||||||
|
break
|
||||||
|
examined += 1
|
||||||
|
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT, TYPE_BOOL]:
|
||||||
|
_omit(location, "unsupported_key")
|
||||||
|
continue
|
||||||
|
var at := location + "[%d]" % entries.size()
|
||||||
|
entries.append({"key": _value(key, level, nesting + 1, at), "value": _value(value[key], level, nesting + 1, at)})
|
||||||
|
return {"entries": entries}
|
||||||
|
TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_RECT2, TYPE_RECT2I, TYPE_AABB, TYPE_PLANE, TYPE_QUATERNION, TYPE_BASIS, TYPE_TRANSFORM2D, TYPE_TRANSFORM3D, TYPE_PROJECTION, TYPE_COLOR:
|
||||||
|
var serialized: Variant = Serializer.serialize(value)
|
||||||
|
if not _charge(JSON.stringify(serialized).to_utf8_buffer().size()):
|
||||||
|
return _omit(location, "result_bytes")
|
||||||
|
return serialized
|
||||||
|
_:
|
||||||
|
return _omit(location, "unsupported_value")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ddbv5xpo41ed3
|
||||||
@@ -14,7 +14,14 @@ extends RefCounted
|
|||||||
## Downscale `image` in place so its longest edge is at most
|
## Downscale `image` in place so its longest edge is at most
|
||||||
## `max_resolution` (0 = no cap), then PNG-encode. Returns
|
## `max_resolution` (0 = no cap), then PNG-encode. Returns
|
||||||
## {base64, width, height, original_width, original_height}.
|
## {base64, width, height, original_width, original_height}.
|
||||||
static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary:
|
static func downscale_and_encode(image: Image, max_resolution: int, use_hdr_2d := false) -> Dictionary:
|
||||||
|
## Compatibility readback stays sRGB, including when HDR 2D is enabled.
|
||||||
|
## Image.linear_to_srgb requires RGB8/RGBA8: converting first quantizes
|
||||||
|
## linear darks and clamps values above 1 rather than tone-mapping them.
|
||||||
|
## Midgray therefore truncates approximately 54.6 -> 54 -> 126.
|
||||||
|
if use_hdr_2d and RenderingServer.get_current_rendering_method() != "gl_compatibility":
|
||||||
|
image.convert(Image.FORMAT_RGBA8)
|
||||||
|
image.linear_to_srgb()
|
||||||
var original_width := image.get_width()
|
var original_width := image.get_width()
|
||||||
var original_height := image.get_height()
|
var original_height := image.get_height()
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ static func finish(id: int) -> void:
|
|||||||
_active.erase(id)
|
_active.erase(id)
|
||||||
|
|
||||||
|
|
||||||
|
static func active_count(label: String = "") -> int:
|
||||||
|
if label.is_empty():
|
||||||
|
return _active.size()
|
||||||
|
return _active.values().count(label)
|
||||||
|
|
||||||
|
|
||||||
static func quiescence() -> Dictionary:
|
static func quiescence() -> Dictionary:
|
||||||
if not _active.is_empty():
|
if not _active.is_empty():
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void:
|
|||||||
_cancel_effect()
|
_cancel_effect()
|
||||||
_replacement_authorization = null
|
_replacement_authorization = null
|
||||||
_transport = null
|
_transport = null
|
||||||
|
var config_error := McpAllowHosts.configuration_error(str(_plan.get("allow_hosts", "")))
|
||||||
|
if not config_error.is_empty():
|
||||||
|
_block_without_effect("unsupported_remote_access", config_error)
|
||||||
|
startup_finished.emit("blocked:unsupported_remote_access")
|
||||||
|
return
|
||||||
if existing_id <= 0:
|
if existing_id <= 0:
|
||||||
_next_episode_id += 1
|
_next_episode_id += 1
|
||||||
existing_id = _next_episode_id
|
existing_id = _next_episode_id
|
||||||
@@ -313,6 +318,7 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
|
|||||||
_ready_since_msec = 0
|
_ready_since_msec = 0
|
||||||
var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size()
|
var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size()
|
||||||
if _endpoint_recovery_attempts >= limit:
|
if _endpoint_recovery_attempts >= limit:
|
||||||
|
_endpoint_recovery_pending_episode = 0
|
||||||
_block(
|
_block(
|
||||||
"endpoint_lost",
|
"endpoint_lost",
|
||||||
"%s Automatic re-probing gave up after %d attempts; click Restart." % [reason, limit],
|
"%s Automatic re-probing gave up after %d attempts; click Restart." % [reason, limit],
|
||||||
@@ -320,6 +326,7 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
|
|||||||
return
|
return
|
||||||
_endpoint_recovery_attempts += 1
|
_endpoint_recovery_attempts += 1
|
||||||
var delay := float(ENDPOINT_RECOVERY_DELAYS_SECONDS[_endpoint_recovery_attempts - 1])
|
var delay := float(ENDPOINT_RECOVERY_DELAYS_SECONDS[_endpoint_recovery_attempts - 1])
|
||||||
|
_endpoint_recovery_pending_episode = int(_episode.get("id", 0))
|
||||||
_block(
|
_block(
|
||||||
"endpoint_lost",
|
"endpoint_lost",
|
||||||
"%s Re-probing in %ds (attempt %d of %d)." % [
|
"%s Re-probing in %ds (attempt %d of %d)." % [
|
||||||
@@ -330,7 +337,6 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void
|
|||||||
"MCP | server endpoint lost (%s); re-probing in %ds (attempt %d of %d)"
|
"MCP | server endpoint lost (%s); re-probing in %ds (attempt %d of %d)"
|
||||||
% [reason, int(delay), _endpoint_recovery_attempts, limit]
|
% [reason, int(delay), _endpoint_recovery_attempts, limit]
|
||||||
)
|
)
|
||||||
_endpoint_recovery_pending_episode = int(_episode.get("id", 0))
|
|
||||||
if bool(_plan.get("automatic_effects", true)):
|
if bool(_plan.get("automatic_effects", true)):
|
||||||
_recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode)
|
_recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode)
|
||||||
|
|
||||||
@@ -700,8 +706,9 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
|
|||||||
if disposition == "unproven":
|
if disposition == "unproven":
|
||||||
return _blocked_probe_result("process_authority_mismatch", port, {}, false,
|
return _blocked_probe_result("process_authority_mismatch", port, {}, false,
|
||||||
"managed process identity could not be proven; click Restart to retry")
|
"managed process identity could not be proven; click Restart to retry")
|
||||||
|
var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
||||||
var capability := _read_capability(port)
|
var capability := _read_capability(port)
|
||||||
var live := _probe_with_capability(port, capability, int(payload.timeout_ms))
|
var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
|
||||||
if _authenticated_status_matches_record(live, capability):
|
if _authenticated_status_matches_record(live, capability):
|
||||||
var version := str(live.get("version", ""))
|
var version := str(live.get("version", ""))
|
||||||
var ws_port := int(live.get("ws_port", 0))
|
var ws_port := int(live.get("ws_port", 0))
|
||||||
@@ -719,7 +726,15 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
|
|||||||
"transport": _transport_from(port, ws_port, live, capability),
|
"transport": _transport_from(port, ws_port, live, capability),
|
||||||
}
|
}
|
||||||
return _blocked_probe_result("incompatible", port, live, true)
|
return _blocked_probe_result("incompatible", port, live, true)
|
||||||
if PortResolver.is_port_in_use(port):
|
var occupied: bool
|
||||||
|
if OS.get_name() == "Windows":
|
||||||
|
var occupancy := PortResolver.windows_port_occupancy(port, listeners)
|
||||||
|
if occupancy == PortResolver.PortOccupancy.UNKNOWN:
|
||||||
|
return _blocked_probe_result("port_occupancy_unknown", port, live)
|
||||||
|
occupied = occupancy == PortResolver.PortOccupancy.OCCUPIED
|
||||||
|
else:
|
||||||
|
occupied = PortResolver.is_port_in_use(port)
|
||||||
|
if occupied:
|
||||||
var detail := _record_probe_failure_detail(capability, live)
|
var detail := _record_probe_failure_detail(capability, live)
|
||||||
var blocked := _blocked_probe_result("occupied", port, live, false, detail)
|
var blocked := _blocked_probe_result("occupied", port, live, false, detail)
|
||||||
var pre_v4 := _untrusted_pre_v4_occupant_version(port, int(payload.timeout_ms))
|
var pre_v4 := _untrusted_pre_v4_occupant_version(port, int(payload.timeout_ms))
|
||||||
@@ -730,8 +745,13 @@ func _effect_probe(payload: Dictionary) -> Dictionary:
|
|||||||
## The server binds both ports before it publishes anything. A held
|
## The server binds both ports before it publishes anything. A held
|
||||||
## WebSocket port (a server moved off the HTTP port, or another editor)
|
## WebSocket port (a server moved off the HTTP port, or another editor)
|
||||||
## would kill the launch at preflight; say so now and name the setting.
|
## would kill the launch at preflight; say so now and name the setting.
|
||||||
if expected_ws_port > 0 and PortResolver.is_port_in_use(expected_ws_port):
|
if expected_ws_port > 0:
|
||||||
return ws_port_blocked_result(expected_ws_port)
|
var ws_occupied := (
|
||||||
|
PortResolver.windows_port_occupancy(expected_ws_port, listeners) == PortResolver.PortOccupancy.OCCUPIED
|
||||||
|
if OS.get_name() == "Windows" else PortResolver.is_port_in_use(expected_ws_port)
|
||||||
|
)
|
||||||
|
if ws_occupied:
|
||||||
|
return ws_port_blocked_result(expected_ws_port)
|
||||||
return {
|
return {
|
||||||
"outcome": "free",
|
"outcome": "free",
|
||||||
"owned_disposition": disposition,
|
"owned_disposition": disposition,
|
||||||
@@ -941,7 +961,8 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
|
|||||||
or str(capability.get("websocket", "")) != str(payload.get("ws_capability", ""))
|
or str(capability.get("websocket", "")) != str(payload.get("ws_capability", ""))
|
||||||
):
|
):
|
||||||
return {"pending": true, "reason": "capability_pair"}
|
return {"pending": true, "reason": "capability_pair"}
|
||||||
var live := _probe_with_capability(port, capability, int(payload.timeout_ms))
|
var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
||||||
|
var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
|
||||||
if not _authenticated_status_matches_record(live, capability):
|
if not _authenticated_status_matches_record(live, capability):
|
||||||
return {"pending": true, "reason": "authenticated_status"}
|
return {"pending": true, "reason": "authenticated_status"}
|
||||||
var version := str(live.get("version", ""))
|
var version := str(live.get("version", ""))
|
||||||
@@ -953,7 +974,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
|
|||||||
var pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
|
var pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
|
||||||
if pid <= 1:
|
if pid <= 1:
|
||||||
return {"pending": true, "reason": "pid_file"}
|
return {"pending": true, "reason": "pid_file"}
|
||||||
if not PortResolver.find_all_pids_on_port(port).has(pid):
|
if not PortResolver.find_all_pids_on_port(port, Callable(), listeners).has(pid):
|
||||||
return {"pending": true, "reason": "listener_pid"}
|
return {"pending": true, "reason": "listener_pid"}
|
||||||
if first_snapshot == null or pid != hinted_pid:
|
if first_snapshot == null or pid != hinted_pid:
|
||||||
first_snapshot = (
|
first_snapshot = (
|
||||||
@@ -973,13 +994,14 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
|
|||||||
## record must still name the authenticated instance, and the process
|
## record must still name the authenticated instance, and the process
|
||||||
## fingerprint must remain unchanged after the final probe.
|
## fingerprint must remain unchanged after the final probe.
|
||||||
var final_capability := _read_capability(port)
|
var final_capability := _read_capability(port)
|
||||||
var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms))
|
var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms), listeners)
|
||||||
var final_snapshot: Variant = _capture_process_snapshot(pid)
|
var final_snapshot: Variant = _capture_process_snapshot(pid)
|
||||||
if PortResolver.capture_failed(final_snapshot):
|
if PortResolver.capture_failed(final_snapshot):
|
||||||
return {"pending": true, "reason": "identity_unavailable"}
|
return {"pending": true, "reason": "identity_unavailable"}
|
||||||
|
var final_listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
||||||
if (
|
if (
|
||||||
PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid
|
PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid
|
||||||
or not PortResolver.find_all_pids_on_port(port).has(pid)
|
or not PortResolver.find_all_pids_on_port(port, Callable(), final_listeners).has(pid)
|
||||||
or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot)
|
or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot)
|
||||||
or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot)
|
or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot)
|
||||||
or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint
|
or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint
|
||||||
@@ -1085,8 +1107,8 @@ func _effect_replace(payload: Dictionary) -> Dictionary:
|
|||||||
)
|
)
|
||||||
return {"ok": false, "reason": "replacement_failed"}
|
return {"ok": false, "reason": "replacement_failed"}
|
||||||
if launch.is_empty():
|
if launch.is_empty():
|
||||||
PortResolver.wait_for_port_free(port, 5.0)
|
var occupancy := PortResolver.wait_for_port_free(port, 5.0)
|
||||||
return {"ok": not PortResolver.is_port_in_use(port), "reason": ""}
|
return {"ok": occupancy == PortResolver.PortOccupancy.FREE, "reason": ""}
|
||||||
return {"ok": true, "reason": "", "launch": launch}
|
return {"ok": true, "reason": "", "launch": launch}
|
||||||
|
|
||||||
|
|
||||||
@@ -1165,13 +1187,14 @@ func _effect_stop(payload: Dictionary) -> Dictionary:
|
|||||||
if disposition == "owned":
|
if disposition == "owned":
|
||||||
grants.append({"pid": pid, "fingerprint": fingerprint})
|
grants.append({"pid": pid, "fingerprint": fingerprint})
|
||||||
PortResolver.kill_exact_processes(grants, true)
|
PortResolver.kill_exact_processes(grants, true)
|
||||||
|
var occupancy := PortResolver.PortOccupancy.FREE
|
||||||
if port > 0 and not grants.is_empty():
|
if port > 0 and not grants.is_empty():
|
||||||
PortResolver.wait_for_port_free(port, 3.0)
|
occupancy = PortResolver.wait_for_port_free(port, 3.0)
|
||||||
var targets_gone := true
|
var targets_gone := true
|
||||||
for exact in grants:
|
for exact in grants:
|
||||||
if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint):
|
if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint):
|
||||||
targets_gone = false
|
targets_gone = false
|
||||||
var port_free := port <= 0 or not PortResolver.is_port_in_use(port)
|
var port_free := port <= 0 or (occupancy != PortResolver.PortOccupancy.UNKNOWN and not PortResolver.is_port_in_use(port))
|
||||||
## Killing something of ours must also free its listener. When there was
|
## Killing something of ours must also free its listener. When there was
|
||||||
## nothing of ours left to kill, whoever holds the port now is not ours to
|
## nothing of ours left to kill, whoever holds the port now is not ours to
|
||||||
## prove stopped; the next start's probe deals with that occupant.
|
## prove stopped; the next start's probe deals with that occupant.
|
||||||
@@ -1283,7 +1306,7 @@ static func probe_live_server_status(
|
|||||||
return _probe_with_capability(port, capability, timeout_ms)
|
return _probe_with_capability(port, capability, timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int) -> Dictionary:
|
static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int, listeners: Dictionary = {}) -> Dictionary:
|
||||||
var result := {"reachable": false, "name": "", "version": "", "ws_port": 0, "instance_id": "", "status_code": 0, "error": ""}
|
var result := {"reachable": false, "name": "", "version": "", "ws_port": 0, "instance_id": "", "status_code": 0, "error": ""}
|
||||||
var http_capability := str(capability.get("http", ""))
|
var http_capability := str(capability.get("http", ""))
|
||||||
if http_capability.is_empty():
|
if http_capability.is_empty():
|
||||||
@@ -1292,7 +1315,10 @@ static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms
|
|||||||
## Windows can otherwise spend the whole connect deadline on an unbound
|
## Windows can otherwise spend the whole connect deadline on an unbound
|
||||||
## loopback port. This is only an unreachable probe; callers still check
|
## loopback port. This is only an unreachable probe; callers still check
|
||||||
## occupancy and prove any later listener before using it.
|
## occupancy and prove any later listener before using it.
|
||||||
if OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port):
|
if (
|
||||||
|
OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port)
|
||||||
|
and PortResolver.windows_port_occupancy(port, listeners) == PortResolver.PortOccupancy.FREE
|
||||||
|
):
|
||||||
result.error = "port_unbound"
|
result.error = "port_unbound"
|
||||||
return result
|
return result
|
||||||
var client := HTTPClient.new()
|
var client := HTTPClient.new()
|
||||||
@@ -1412,7 +1438,9 @@ static func _blocked_probe_result(
|
|||||||
var message := "Port %d is occupied by another process." % port
|
var message := "Port %d is occupied by another process." % port
|
||||||
if not detail.is_empty():
|
if not detail.is_empty():
|
||||||
message = "Port %d is occupied by another process (%s)." % [port, detail]
|
message = "Port %d is occupied by another process (%s)." % [port, detail]
|
||||||
if replaceable:
|
if reason == "port_occupancy_unknown":
|
||||||
|
message = "Windows could not query listening ports; retry when port discovery is available."
|
||||||
|
elif replaceable:
|
||||||
message = "Port %d is occupied by godot-ai v%s; choose Replace to continue." % [port, version]
|
message = "Port %d is occupied by godot-ai v%s; choose Replace to continue." % [port, version]
|
||||||
return {
|
return {
|
||||||
"outcome": "blocked",
|
"outcome": "blocked",
|
||||||
@@ -1627,6 +1655,13 @@ func get_status_dict() -> Dictionary:
|
|||||||
return {
|
return {
|
||||||
"episode_id": int(_episode.get("id", 0)),
|
"episode_id": int(_episode.get("id", 0)),
|
||||||
"episode_state": state,
|
"episode_state": state,
|
||||||
|
"reason": reason,
|
||||||
|
"recovery_pending": (
|
||||||
|
state == BLOCKED and reason == "endpoint_lost"
|
||||||
|
and _endpoint_recovery_pending_episode > 0
|
||||||
|
and _endpoint_recovery_pending_episode == int(_episode.get("id", 0))
|
||||||
|
),
|
||||||
|
"episode_reason": reason,
|
||||||
"phase": str(_episode.get("phase", "")),
|
"phase": str(_episode.get("phase", "")),
|
||||||
"ready_kind": str(_episode.get("ready_kind", "")),
|
"ready_kind": str(_episode.get("ready_kind", "")),
|
||||||
"state": _dock_state(state, reason),
|
"state": _dock_state(state, reason),
|
||||||
@@ -1713,9 +1748,10 @@ static func _dock_state(state: String, reason: String) -> int:
|
|||||||
return ServerState.STOPPING
|
return ServerState.STOPPING
|
||||||
BLOCKED:
|
BLOCKED:
|
||||||
match reason:
|
match reason:
|
||||||
|
"unsupported_remote_access": return ServerState.UNSUPPORTED_CONFIG
|
||||||
"incompatible": return ServerState.INCOMPATIBLE
|
"incompatible": return ServerState.INCOMPATIBLE
|
||||||
"no_command": return ServerState.NO_COMMAND
|
"no_command": return ServerState.NO_COMMAND
|
||||||
"port_reserved": return ServerState.PORT_EXCLUDED
|
"port_reserved": return ServerState.PORT_EXCLUDED
|
||||||
"occupied", "replacement_unproven": return ServerState.FOREIGN_PORT
|
"occupied", "replacement_unproven", "port_occupancy_unknown": return ServerState.FOREIGN_PORT
|
||||||
_: return ServerState.CRASHED
|
_: return ServerState.CRASHED
|
||||||
return ServerState.UNINITIALIZED
|
return ServerState.UNINITIALIZED
|
||||||
|
|||||||
Reference in New Issue
Block a user