diff --git a/addons/godot_ai/client_configurator.gd b/addons/godot_ai/client_configurator.gd index bd3980f..0f539ff 100644 --- a/addons/godot_ai/client_configurator.gd +++ b/addons/godot_ai/client_configurator.gd @@ -132,6 +132,9 @@ static func prepare_major_upgrade_endpoints(from_version: String, to_version: St return {"ok": false, "error": "EditorSettings is unavailable"} 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 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 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) @@ -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) if candidate < MIN_PORT or candidate > MAX_PORT: 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): - selected.append(candidate) - if selected.size() == 2: - break + if candidate not in [legacy_http, legacy_ws] and not selected.has(candidate) and PortResolver.can_bind_local_port(candidate): + var available: bool + if OS.get_name() == "Windows": + 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 if candidate > MAX_PORT: candidate = MIN_PORT if selected.size() != 2: 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: - 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} var pair := {"http_port": selected[0], "ws_port": selected[1]} 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 ## next port up may still be occupied — the same suggestion feeds the dock ## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE -## log line. Falls back to the clamped candidate if nothing in the window -## clears both checks (caller surfaces it as a best-effort hint; the user can -## retry or pick another). Best-effort by nature: a TOCTOU window remains +## log line. Returns zero when discovery fails or no candidate is +## available. A TOCTOU window remains ## between the probe and the caller actually binding the port. The bind probe ## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't ## stall on a pathological run of occupied ports. -static func suggest_free_port(start: int, span: int = 2048) -> int: +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 limit := mini(candidate + span - 1, MAX_PORT) var p := candidate @@ -556,10 +578,11 @@ static func suggest_free_port(start: int, span: int = 2048) -> int: break p = not_reserved 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 p += 1 - return candidate + return 0 # --- 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")]: if env_name is String and not env_name.is_empty() and not extras.has(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) UvResolution.warm_environment() _editor_setting_lookup(MODE_OVERRIDE_SETTING) diff --git a/addons/godot_ai/clients/_base.gd b/addons/godot_ai/clients/_base.gd index 0483496..ab27fe0 100644 --- a/addons/godot_ai/clients/_base.gd +++ b/addons/godot_ai/clients/_base.gd @@ -89,16 +89,55 @@ var path_template: Dictionary = {} ## `path_template` remains the fallback. var config_path_candidates: Dictionary = {} -## Optional global JSON config files merged by the client from lowest to highest -## precedence. Unlike `config_path_candidates`, every existing file participates; -## Configure updates the effective last definition, status verifies it, and -## Remove clears every global definition transactionally. +## Optional global JSON config files merged by the client. Unlike +## `config_path_candidates`, every existing file participates. By default the +## client keeps the LAST definition: Configure updates the effective last +## 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 = {} ## Project-relative tiers that may override the global files. Their root is the ## external client's working directory, which the Godot process cannot know. ## The strategy checks plausible roots and fails closed instead of mutating them. 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 ## refreshes. The actionable message still returns on every resolution; only ## the editor-console echo is single-shot until the ambiguity clears/changes. diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd index e7f9a50..9930416 100644 --- a/addons/godot_ai/clients/_json_strategy.gd +++ b/addons/godot_ai/clients/_json_strategy.gd @@ -62,6 +62,12 @@ static func _configure_merged( var launch_error := command_launch_error(client, launch) if not launch_error.is_empty(): 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) if not project.get("ok", false): 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", []) if tiers.is_empty(): 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 for index in range(tiers.size()): var config: Dictionary = tiers[index]["data"] var holder := _walk_path(config, select_server_key_path(config, client)) if holder is Dictionary and holder.has(server_name): 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 config: Dictionary = tier["data"] var holder := _ensure_path(config, select_server_key_path(config, client)) 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) var path := str(tier["path"]) # 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) -## 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( client: McpClient, server_name: String, @@ -156,39 +172,60 @@ static func _check_status_merged( launch: Dictionary, project_roots: PackedStringArray, ) -> 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 loaded := _load_merge_tiers(client, allow_comments) if not loaded.get("ok", false): return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))} var effective: Variant = null + var effective_path := "" for tier in loaded.get("tiers", []): var config: Dictionary = tier["data"] var holder := _walk_path(config, select_server_key_path(config, client)) if holder is Dictionary and holder.has(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) if not project.get("ok", false): return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config 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(): - # Last-definition-wins mirrors the global-tier fold above and how - # pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead - # once a later one defines the same server, so an early-stale entry - # doesn't make Pi's effective config drift (codex-review finding F2). - # Pass `[latest]` to `_project_override_message` so the error names only - # the file the user actually has to edit. - var latest: Dictionary = project_tiers[project_tiers.size() - 1] - var details := _entry_status_details(client, latest["entry"], server_url, launch) + # The project tier that drives the client's effective config: the + # latest for last-wins clients (pi-codemode-mcp merges project tiers + # that way; earlier tiers are dead once a later one defines the same + # server — codex-review finding F2), the earliest for first-wins + # clients (omp reads `.omp/mcp.json` before `.omp/.mcp.json`). Pass + # `[effective_tier]` to `_project_override_message` so the error names + # only the file the user actually has to edit. + 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: ## Keep `owned` from the effective entry: the post-update migration ## decides from it whether this mismatch is ours to repin. var mismatch := details.duplicate() 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 {"status": McpClient.Status.CONFIGURED, "error_msg": ""} if effective == null: 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) @@ -247,6 +284,12 @@ static func remove( static func _remove_merged( client: McpClient, server_name: String, project_roots: PackedStringArray ) -> 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) if not project.get("ok", false): 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") 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. 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. ## ## Resolution order (codex round 3, F-3-4): -## 1. Latest project tier containing the entry — F2 last-wins means the -## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`). -## 2. Latest global tier containing the entry — already iterated in -## merge order, last-iterated-wins (same loop pattern as -## `manual_target_details` lines 660-665). +## 1. The project tier that wins the client's fold — the latest of +## `.pi/mcp.json` and `.mcp.json` for last-wins clients, the earliest +## for first-wins clients (matches `_check_status_merged`). +## 2. The global tier that wins the client's fold — for last-wins clients +## 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 ## (`path_template`) to use. ## @@ -789,24 +890,28 @@ static func authoritative_tier_path( return "" var project_tiers: Array = project.get("tiers", []) if not project_tiers.is_empty(): - # F2 last-wins: latest project tier is authoritative. When there are - # multiple project tiers, the user-visible status is driven by the - # latest, so the Open/Reveal buttons should send them there too. - var latest: Dictionary = project_tiers[project_tiers.size() - 1] - return str(latest.get("path", "")) + # The project tier that drives the user-visible status (F2: latest for + # last-wins clients, earliest for first-wins ones), so the Open/Reveal + # buttons send the user to the file they actually have to edit. + var effective_index := 0 if _first_wins(client) else project_tiers.size() - 1 + var effective_tier: Dictionary = project_tiers[effective_index] + return str(effective_tier.get("path", "")) var loaded := _load_merge_tiers(client, allow_comments) if not bool(loaded.get("ok", false)): return "" var tiers: Array = loaded.get("tiers", []) - # Iterate in priority order, overwrite `selected_path` each time we - # find a tier containing the entry. With Pi's merge path order - # [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`. + # Iterate in the client's read order. Last-wins clients overwrite + # `selected_path` on every match (Pi's [mcp.json, .mcp.json] order then + # 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 = "" for tier in tiers: var data: Dictionary = tier.get("data", {}) var holder: Variant = _walk_path(data, select_server_key_path(data, client)) if holder is Dictionary and holder.has(server_name): selected_path = str(tier.get("path", "")) + if _first_wins(client): + break return selected_path @@ -845,6 +950,10 @@ static func manual_target_details( var holder := _walk_path(config, select_server_key_path(config, client)) if holder is Dictionary and holder.has(server_name): 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: var config: Dictionary = selected["data"] return { diff --git a/addons/godot_ai/clients/_manual_command.gd b/addons/godot_ai/clients/_manual_command.gd index fef2ffc..b5253d2 100644 --- a/addons/godot_ai/clients/_manual_command.gd +++ b/addons/godot_ai/clients/_manual_command.gd @@ -199,7 +199,10 @@ static func _build_json( target = {"path": resolved_path, "key_path": client.server_key_path} var target_path := str(target.get("path", resolved_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: var lines: Array[String] = [] var launch_error := McpJsonStrategy.command_launch_error(client, launch) diff --git a/addons/godot_ai/clients/_registry.gd b/addons/godot_ai/clients/_registry.gd index 000317b..a03c7f0 100644 --- a/addons/godot_ai/clients/_registry.gd +++ b/addons/godot_ai/clients/_registry.gd @@ -37,6 +37,8 @@ const _CLIENT_SCRIPT_PATHS := [ "res://addons/godot_ai/clients/hermes.gd", "res://addons/godot_ai/clients/pi.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] = [] diff --git a/addons/godot_ai/clients/omp.gd b/addons/godot_ai/clients/omp.gd new file mode 100644 index 0000000..1965d77 --- /dev/null +++ b/addons/godot_ai/clients/omp.gd @@ -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"]) diff --git a/addons/godot_ai/clients/omp.gd.uid b/addons/godot_ai/clients/omp.gd.uid new file mode 100644 index 0000000..16f26e8 --- /dev/null +++ b/addons/godot_ai/clients/omp.gd.uid @@ -0,0 +1 @@ +uid://b1daig0jwxvfb diff --git a/addons/godot_ai/clients/zcode.gd b/addons/godot_ai/clients/zcode.gd new file mode 100644 index 0000000..58040a5 --- /dev/null +++ b/addons/godot_ai/clients/zcode.gd @@ -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 `/.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"]) diff --git a/addons/godot_ai/clients/zcode.gd.uid b/addons/godot_ai/clients/zcode.gd.uid new file mode 100644 index 0000000..6568182 --- /dev/null +++ b/addons/godot_ai/clients/zcode.gd.uid @@ -0,0 +1 @@ +uid://b0xpusa88vykn diff --git a/addons/godot_ai/connection.gd b/addons/godot_ai/connection.gd index 5877356..675d0cb 100644 --- a/addons/godot_ai/connection.gd +++ b/addons/godot_ai/connection.gd @@ -109,13 +109,7 @@ var _packet_spillover_total := 0 func _ready() -> void: _session_id = _make_session_id(ProjectSettings.globalize_path("res://")) - ## Increase outbound buffer for large messages (e.g. screenshot base64). - ## 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 + _configure_peer_buffers(_peer) if connect_blocked: _log_blocked_notice_once() set_process(false) @@ -375,12 +369,22 @@ func _attempt_reconnect() -> void: ## a quiet reconnect loop after the Python server restarts. _peer = WebSocketPeer.new() _preopen_failure_logged_for_peer = false - _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES - _peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES - _peer.max_queued_packets = MAX_QUEUED_PACKETS + _configure_peer_buffers(_peer) _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: _pause_depth += 1 @@ -647,7 +651,6 @@ func _handle_auth_challenge(parsed: Dictionary) -> void: _server_nonce = str(parsed["server_nonce"]) _challenged_server_version = str(parsed["server_version"]) _server_verified = true - _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES _last_readiness = get_readiness() var response := _build_auth_response() if response.is_empty() or not _send_json(response, true): diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd b/addons/godot_ai/dock_panels/port_picker_panel.gd index ea935b4..10bdc09 100644 --- a/addons/godot_ai/dock_panels/port_picker_panel.gd +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd @@ -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 ## in practice because the dock only calls this on `server_status` ## 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: return + if OS.get_name() == "Windows" and occupancy.is_empty(): + occupancy = PortResolver.windows_listener_snapshot() var http := ClientConfigurator.http_port() var ws := ClientConfigurator.ws_port() - if conflict_port == http or bool(port_in_use_probe.call(http)): - http = ClientConfigurator.suggest_free_port(http + 1) - if conflict_port == ws or ws == http or bool(port_in_use_probe.call(ws)): - ws = ClientConfigurator.suggest_free_port(ws + 1) - if ws == http: - ws = ClientConfigurator.suggest_free_port(ws + 1) + var http_occupied: bool + var ws_occupied: bool + if OS.get_name() == "Windows": + http_occupied = PortResolver.windows_port_occupancy(http, occupancy) != PortResolver.PortOccupancy.FREE + ws_occupied = PortResolver.windows_port_occupancy(ws, occupancy) != PortResolver.PortOccupancy.FREE + 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 _ws_spinbox.value = ws diff --git a/addons/godot_ai/handlers/animation_values.gd b/addons/godot_ai/handlers/animation_values.gd index ab6a1e9..20a48c7 100644 --- a/addons/godot_ai/handlers/animation_values.gd +++ b/addons/godot_ai/handlers/animation_values.gd @@ -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) +## 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 ## {"ok": coerced} or {"error": msg}. Unknown types pass through. 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: if value is int or value is float or value is bool: 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} diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd index ec889c1..8649867 100644 --- a/addons/godot_ai/handlers/editor_handler.gd +++ b/addons/godot_ai/handlers/editor_handler.gd @@ -500,7 +500,7 @@ func _take_screenshot_impl(params: Dictionary) -> Dictionary: "viewport_2d", "Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet." ) - return _finalize_image(image_2d, "viewport_2d", max_resolution) + return _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) @@ -848,9 +848,9 @@ func _find_current_camera_3d(root: Node) -> Camera3D: 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). - var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution) + var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution, use_hdr_2d) return { "data": { "source": source, diff --git a/addons/godot_ai/handlers/filesystem_handler.gd b/addons/godot_ai/handlers/filesystem_handler.gd index 16c46f5..b15c785 100644 --- a/addons/godot_ai/handlers/filesystem_handler.gd +++ b/addons/godot_ai/handlers/filesystem_handler.gd @@ -318,3 +318,19 @@ static func _settle_scan( "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") diff --git a/addons/godot_ai/handlers/filesystem_mutation.gd b/addons/godot_ai/handlers/filesystem_mutation.gd new file mode 100644 index 0000000..aac8fb3 --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_mutation.gd @@ -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 diff --git a/addons/godot_ai/handlers/filesystem_mutation.gd.uid b/addons/godot_ai/handlers/filesystem_mutation.gd.uid new file mode 100644 index 0000000..3df4ee5 --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_mutation.gd.uid @@ -0,0 +1 @@ +uid://c21v8ma38tvs7 diff --git a/addons/godot_ai/handlers/navigation_handler.gd b/addons/godot_ai/handlers/navigation_handler.gd new file mode 100644 index 0000000..4fed011 --- /dev/null +++ b/addons/godot_ai/handlers/navigation_handler.gd @@ -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} diff --git a/addons/godot_ai/handlers/navigation_handler.gd.uid b/addons/godot_ai/handlers/navigation_handler.gd.uid new file mode 100644 index 0000000..6ef3ea5 --- /dev/null +++ b/addons/godot_ai/handlers/navigation_handler.gd.uid @@ -0,0 +1 @@ +uid://dsm4acmlregit diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd b/addons/godot_ai/handlers/physics_shape_handler.gd index b958abb..83027e2 100644 --- a/addons/godot_ai/handlers/physics_shape_handler.gd +++ b/addons/godot_ai/handlers/physics_shape_handler.gd @@ -1,6 +1,7 @@ @tool 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") ## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's @@ -26,6 +27,19 @@ const _SHAPE_3D_CLASSES := { "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 := { "rectangle": "RectangleShape2D", "circle": "CircleShape2D", @@ -35,7 +49,16 @@ const _SHAPE_2D_CLASSES := { const _GENERATED_BODY_CLASSES := { "static": "StaticBody3D", "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_MAX_PATHS := 1024 ## 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_COLLIDER_SUFFIX := "Collider" 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 @@ -104,11 +137,11 @@ func generate(params: Dictionary) -> Dictionary: ## edited scene. Nothing here touches a node. static func _validate_generate_request(params: Dictionary) -> Dictionary: 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(): return ErrorCodes.make( 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")) 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, "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", []) if not raw_paths is Array: return ErrorCodes.make( @@ -158,13 +220,44 @@ static func _validate_generate_request(params: Dictionary) -> Dictionary: "scene_file": params.get("scene_file", ""), "shape_type": shape_type, "body_type": body_type, + "reparent_mesh": reparent_mesh, "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. 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: var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file) 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()] ) 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() + 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: return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, @@ -192,6 +289,13 @@ static func _plan_generate_mesh( ErrorCodes.RESOURCE_NOT_FOUND, "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 existing := parent.get_node_or_null(NodePath(collider_name)) 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" % [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 ## 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 - ## deformed collider). A top-level mesh ignores its parent's transform. + ## sphere, capsule, cylinder or concave shape non-uniformly (the warning it + ## 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: var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale() 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()), 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 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: @@ -231,11 +346,17 @@ static func _plan_generate_mesh( return {"plan": { "mesh": mesh, "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, "collider_name": collider_name, "top_level": mesh.top_level, "source_transform": source_transform, "body_transform": body_transform, + "mesh_to_body": mesh_to_body, "bounds": bounds, }} @@ -245,15 +366,23 @@ static func _plan_generate_mesh( ## request can never apply plan-time state to a mesh that moved, was ## reparented, lost its mesh, gained a collider, or went away meanwhile. 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 ## instance for the rest of the frame; not being in the tree is what ## "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" - var parent: Node = plan.parent - if not is_instance_valid(parent) or mesh.get_parent() != parent: + var mesh: MeshInstance3D = mesh_ref + 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" + var parent: Node = parent_ref if mesh.top_level != bool(plan.top_level): return "changed its top_level setting" 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" if mesh.mesh == null: 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: return "gained a collider sibling" 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. +## Returns `{error: ...}` when the mesh cannot produce the requested shape +## (the engine refuses a degenerate hull). 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: 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]) body.name = str(plan.collider_name) body.top_level = bool(plan.top_level) body.transform = plan.body_transform var collision := CollisionShape3D.new() collision.name = "CollisionShape3D" - var bounds: AABB = plan.bounds - collision.position = bounds.get_center() - var shape: Shape3D = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type]) - _apply_shape_size(shape, shape_type, {"aabb": bounds}, true) + if shape_type != "convex" and shape_type != "trimesh": + collision.position = _snap_tiny(bounds.get_center()) collision.shape = shape body.add_child(collision) - return { + var entry := { "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, "body": body, "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. +## 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( created_nodes: Array[Dictionary], scene_root: Node, undo_redo: EditorUndoRedoManager, execute: bool, ) -> 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: + 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 body: CollisionObject3D = entry.body var collision: CollisionShape3D = entry.collision 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(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_undo_method(parent, "remove_child", body) 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. 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: var created: Array[Dictionary] = [] for entry in created_nodes: @@ -321,8 +601,9 @@ static func _generated_response( "mesh_path": McpScenePath.from_node(entry.mesh, scene_root), "body_path": McpScenePath.from_node(entry.body, 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, + "operation": "refresh" if bool(entry.get("refresh", false)) else "create", }) return {"data": {"created": created, "undoable": true}} @@ -335,6 +616,7 @@ static func _generate_job( validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String ) -> Dictionary: return { + "refresh_worker": Refresh.new(), "validated": validated, "undo_redo": undo_redo, "connection": connection, @@ -343,6 +625,7 @@ static func _generate_job( "index": 0, "plans": [], "created": [], + "committed": false, "result": {}, "started_ms": Time.get_ticks_msec(), } @@ -354,6 +637,8 @@ static func _generate_job( static func _generate_step(job: Dictionary, budget_usec: int) -> bool: if str(job.phase) == "done": return true + if bool(job.validated.get("overwrite", false)): + return job.refresh_worker.step(job, budget_usec) var validated: Dictionary = job.validated var connection = job.connection if connection != null: @@ -368,12 +653,16 @@ static func _generate_step(job: Dictionary, budget_usec: int) -> bool: ErrorCodes.DEFERRED_TIMEOUT, "physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS, )) - var scene_root: Node = validated.scene_root - if not is_instance_valid(scene_root) or EditorInterface.get_edited_scene_root() != scene_root: + ## Same freed-instance rule as the plan check: a scene root freed while the + ## 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( ErrorCodes.EDITED_SCENE_MISMATCH, "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 ## makes progress and a phase that just finished its last item moves on. 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: return false 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"): 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" % [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 parent.add_child(entry.body, true) entry.body.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.index = int(job.index) + 1 work += 1 - ## The nodes are already in the scene, so commit with execute=false: one - ## atomic undo/redo action without replaying every mutation now. + ## Revalidate every applied entry before the batch is committed: a mesh, + ## 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] = [] 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) + job.committed = true 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" 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. 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: - 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): 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: parent.remove_child(body) body.free() 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. static func _deferred_request_pending(connection, request_id: String) -> bool: 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. ## `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): + _cancel_generate_job(job) + return + if not connection.is_inside_tree(): + _cancel_generate_job(job) return var tree: SceneTree = connection.get_tree() if tree == null: + _cancel_generate_job(job) 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 - ## any validation error or successful result can be sent. - await tree.process_frame - while is_instance_valid(connection) and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC): - await tree.process_frame - if is_instance_valid(connection) and not job.result.is_empty(): + ## any validation error or successful result can be sent. The abandoned-request + ## check belongs in `_generate_step`, after this yield: before the handler + ## returns its deferred sentinel the dispatcher has not registered the request + ## yet, so checking here would cancel every real call. + 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) + _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: diff --git a/addons/godot_ai/handlers/physics_shape_refresh.gd b/addons/godot_ai/handlers/physics_shape_refresh.gd new file mode 100644 index 0000000..e5777ee --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_refresh.gd @@ -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 diff --git a/addons/godot_ai/handlers/physics_shape_refresh.gd.uid b/addons/godot_ai/handlers/physics_shape_refresh.gd.uid new file mode 100644 index 0000000..df5decd --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_refresh.gd.uid @@ -0,0 +1 @@ +uid://b8xkp8fhvdf5k diff --git a/addons/godot_ai/handlers/resource_handler.gd b/addons/godot_ai/handlers/resource_handler.gd index c43a6d3..49a659d 100644 --- a/addons/godot_ai/handlers/resource_handler.gd +++ b/addons/godot_ai/handlers/resource_handler.gd @@ -2,6 +2,7 @@ extends "res://addons/godot_ai/handlers/command_handler.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") ## 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) if loaded == null: return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, + ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found at path '%s' for property '%s'" % [v, key] ) v = loaded @@ -589,3 +590,35 @@ static func _custom_resource_info(type_str: String) -> Variant: "property_count": props.size(), }} 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)) diff --git a/addons/godot_ai/handlers/script_handler.gd b/addons/godot_ai/handlers/script_handler.gd index 050b243..8841698 100644 --- a/addons/godot_ai/handlers/script_handler.gd +++ b/addons/godot_ai/handlers/script_handler.gd @@ -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") ## 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 _connection: McpConnection @@ -27,8 +50,11 @@ func create_script(params: Dictionary) -> Dictionary: if path_err != null: return path_err - if not path.ends_with(".gd"): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd") + var language := script_language(path) + 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) @@ -42,12 +68,16 @@ func create_script(params: Dictionary) -> Dictionary: "path": path, "size": content.length(), "committed": true, - "import_settled": existed_before, - "import_settle": "already_known" if existed_before else "not_waited", + "import_settled": existed_before and can_settle, + "import_settle": "already_known" if existed_before and can_settle else "not_waited", "undoable": false, "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 # 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 # class from a broken script, so pointing at op="scan" would steer the caller # away from the real fix (the parse error already attached above). - var declared_class := _extract_class_name(content) + var declared_class := "" if is_csharp else _extract_class_name(content) if ( not declared_class.is_empty() 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 # update_file() (see _refresh_loaded_gdscript for the ordering contract). 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) # `.gd.uid` is the sidecar Godot generates on scan; list both so the caller - # can rm the full set in one go. - McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"]) + # can rm the full set in one go. A `.cs` only gets a sidecar on a .NET + # 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 # filesystem pipeline finishes. If we reply now, an immediate attach_script # races and 404s (#261). Defer the response until the resource is visible # (or a bounded timeout elapses). For freshly-created files we wait; on # 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", "") - 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) return McpDispatcher.DEFERRED_RESPONSE @@ -106,6 +142,51 @@ func create_script(params: Dictionary) -> Dictionary: 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 ## (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: @@ -340,8 +421,10 @@ func patch_script(params: Dictionary) -> Dictionary: return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text") if not "new_text" in params: return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text") - if not path.ends_with(".gd"): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)") + var language := script_language(path) + 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(): 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(), "undoable": false, "reason": "File system operations cannot be undone via editor undo", + "language": language, } - _attach_gdscript_diagnostics(data, path, new_content) - - # The file is fresh but any already-loaded GDScript for it is not (#937); - # 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) + if is_csharp: + _attach_csharp_not_checked(data) + _mark_csharp_not_reloaded(data) + else: + _attach_gdscript_diagnostics(data, path, new_content) + # The file is fresh but any already-loaded GDScript for it is not (#937); + # 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) return {"data": data} @@ -411,6 +498,17 @@ func attach_script(params: Dictionary) -> Dictionary: if spath_err != null: 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") if _resolved.has("error"): return _resolved @@ -418,11 +516,17 @@ func attach_script(params: Dictionary) -> Dictionary: var _scene_root: Node = _resolved.scene_root 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) 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() @@ -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: var node_path: String = params.get("path", "") @@ -478,6 +595,10 @@ func find_symbols(params: Dictionary) -> Dictionary: if path_err != null: 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): 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() file.close() + if language == LANGUAGE_CSHARP: + return {"data": _csharp_symbols(path, content)} + var functions: Array[Dictionary] = [] var signals_list: Array[String] = [] var exports: Array[Dictionary] = [] @@ -560,6 +684,7 @@ func find_symbols(params: Dictionary) -> Dictionary: return { "data": { "path": path, + "language": LANGUAGE_GDSCRIPT, "class_name": class_name_str, "extends": extends_str, "functions": functions, @@ -570,3 +695,109 @@ func find_symbols(params: Dictionary) -> Dictionary: "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(), + } diff --git a/addons/godot_ai/handlers/shader_handler.gd b/addons/godot_ai/handlers/shader_handler.gd new file mode 100644 index 0000000..0ba3e24 --- /dev/null +++ b/addons/godot_ai/handlers/shader_handler.gd @@ -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 ; #include ""` 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) diff --git a/addons/godot_ai/handlers/shader_handler.gd.uid b/addons/godot_ai/handlers/shader_handler.gd.uid new file mode 100644 index 0000000..6de2623 --- /dev/null +++ b/addons/godot_ai/handlers/shader_handler.gd.uid @@ -0,0 +1 @@ +uid://ufuy74ofmra1 diff --git a/addons/godot_ai/handlers/theme_handler.gd b/addons/godot_ai/handlers/theme_handler.gd index 519989c..4e6ee0f 100644 --- a/addons/godot_ai/handlers/theme_handler.gd +++ b/addons/godot_ai/handlers/theme_handler.gd @@ -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" +## 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 _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): theme.clear_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: @@ -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): theme.clear_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). -# Captures old value, applies new value, saves to disk, registers undo that -# restores the old value and saves again. +# Captures old value, applies new value, persists it as a pre-flight save, then +# 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( params: Dictionary, kind: String, @@ -147,20 +191,34 @@ func _set_scalar( var parsed = parser.call(raw_value) if parsed == null: ## 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, "Invalid %s value: %s (%s)" % [kind, raw_value, hint]) 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 + ## 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.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed) if had_before: _undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value) else: _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 { "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) if theme == null: 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) - 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) if theme == null: 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) - 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) ## ## 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: var load_result := _load_theme_from_params(params) 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") var sb := StyleBoxFlat.new() - 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 - 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 applied := _apply_flat_props(sb, params) + if applied.has("error"): + return applied 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 - - _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() + ## 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": { @@ -332,50 +350,514 @@ func set_stylebox_flat(params: Dictionary) -> Dictionary: } -## Parse a {all, , , ...} dict and apply it to StyleBoxFlat via -## its set_ properties. Returns "" on success, an error -## message on failure. Validates that only known keys are present. -func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String, - side_names: Array, prop_prefix: String, value_type: int) -> String: +## Apply the StyleBoxFlat property vocabulary shared by `set_stylebox_flat` +## and `stylebox_override` to an existing StyleBoxFlat. Returns `{ok: true}` +## or the error dict to surface. +func _apply_flat_props(sb: StyleBoxFlat, params: Dictionary) -> Dictionary: + 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": }`; 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, , , ...} 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: - 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} for s in side_names: valid_keys[s] = true for k in sides_dict.keys(): if not valid_keys.has(k): - return "Unknown key in '%s': %s (valid: all, %s)" % [ - dict_name, k, ", ".join(side_names) - ] - # Apply `all` first, then override with side-specific keys. + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Unknown key in '%s': %s (valid: all, %s)" % [dict_name, k, ", ".join(side_names)]) + var parsed_values := {} if sides_dict.has("all"): - var all_val: Variant = sides_dict.all - for s in side_names: - var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val) - sb.set(prop_prefix + s, v) + var all_result := _parse_number_field(dict_name, "all", sides_dict.all, storage) + if all_result.has("error"): + return all_result + parsed_values["all"] = all_result.value for s in side_names: if sides_dict.has(s): - var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s]) - sb.set(prop_prefix + s, v2) - return "" + var side_result := _parse_number_field(dict_name, s, sides_dict[s], storage) + if side_result.has("error"): + 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) if theme == null: 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) - 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) if theme == null: 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) - 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. ## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array ## 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: - 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: @@ -473,4 +963,94 @@ static func _serialize_value(value: Variant) -> Variant: return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} if value is Vector2: 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 + + +## 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) diff --git a/addons/godot_ai/handlers/ui_handler.gd b/addons/godot_ai/handlers/ui_handler.gd index f183751..197ff9f 100644 --- a/addons/godot_ai/handlers/ui_handler.gd +++ b/addons/godot_ai/handlers/ui_handler.gd @@ -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 # ============================================================================ diff --git a/addons/godot_ai/handlers/visual_shader_handler.gd b/addons/godot_ai/handlers/visual_shader_handler.gd new file mode 100644 index 0000000..46ba585 --- /dev/null +++ b/addons/godot_ai/handlers/visual_shader_handler.gd @@ -0,0 +1,1049 @@ +@tool +extends "res://addons/godot_ai/handlers/command_handler.gd" + +## Create, read, and edit VisualShader resources. Scene assignment is separate. +## +## `create_graph` builds a fully validated graph from a declarative spec; +## `get_graph` reports an existing graph (stages, nodes, params, connections, +## varyings); `edit_graph` applies a validated operation list to an existing +## resource; `node_catalog` lists the instantiable VisualShaderNode classes +## with the properties this handler accepts. Every write validates the whole +## result in memory first and saves through a same-directory temporary, so a +## failure never truncates the destination. + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") + +const MAX_NODES := 256 +const MAX_CONNECTIONS := 1024 +const MAX_OPERATIONS := 512 +const MAX_CATALOG_LIMIT := 500 +const MODES := { + "spatial": Shader.MODE_SPATIAL, "canvas_item": Shader.MODE_CANVAS_ITEM, + "particles": Shader.MODE_PARTICLES, "sky": Shader.MODE_SKY, "fog": Shader.MODE_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 STAGES := { + "vertex": VisualShader.TYPE_VERTEX, "fragment": VisualShader.TYPE_FRAGMENT, + "light": VisualShader.TYPE_LIGHT, "start": VisualShader.TYPE_START, + "process": VisualShader.TYPE_PROCESS, "collide": VisualShader.TYPE_COLLIDE, + "start_custom": VisualShader.TYPE_START_CUSTOM, "process_custom": VisualShader.TYPE_PROCESS_CUSTOM, + "sky": VisualShader.TYPE_SKY, "fog": VisualShader.TYPE_FOG, +} +const STAGE_NAMES := { + VisualShader.TYPE_VERTEX: "vertex", VisualShader.TYPE_FRAGMENT: "fragment", + VisualShader.TYPE_LIGHT: "light", VisualShader.TYPE_START: "start", + VisualShader.TYPE_PROCESS: "process", VisualShader.TYPE_COLLIDE: "collide", + VisualShader.TYPE_START_CUSTOM: "start_custom", + VisualShader.TYPE_PROCESS_CUSTOM: "process_custom", + VisualShader.TYPE_SKY: "sky", VisualShader.TYPE_FOG: "fog", +} +const MODE_STAGES := { + "spatial": ["vertex", "fragment", "light"], + "canvas_item": ["vertex", "fragment", "light"], + "particles": ["start", "process", "collide", "start_custom", "process_custom"], + "sky": ["sky"], "fog": ["fog"], +} +## Varyings are a spatial/canvas_item feature; particles/sky/fog have no +## vertex-to-fragment or fragment-to-light stage pair to carry them. +const VARYING_MODES := { + "spatial": true, "canvas_item": true, +} +const VARYING_MODE_VALUES := { + "vertex_to_frag_light": VisualShader.VARYING_MODE_VERTEX_TO_FRAG_LIGHT, + "frag_to_light": VisualShader.VARYING_MODE_FRAG_TO_LIGHT, +} +const VARYING_TYPE_VALUES := { + "float": VisualShader.VARYING_TYPE_FLOAT, + "int": VisualShader.VARYING_TYPE_INT, + "uint": VisualShader.VARYING_TYPE_UINT, + "vector2": VisualShader.VARYING_TYPE_VECTOR_2D, + "vector3": VisualShader.VARYING_TYPE_VECTOR_3D, + "vector4": VisualShader.VARYING_TYPE_VECTOR_4D, + "boolean": VisualShader.VARYING_TYPE_BOOLEAN, + "transform": VisualShader.VARYING_TYPE_TRANSFORM, +} +const VARYING_MODE_NAMES := { + VisualShader.VARYING_MODE_VERTEX_TO_FRAG_LIGHT: "vertex_to_frag_light", + VisualShader.VARYING_MODE_FRAG_TO_LIGHT: "frag_to_light", +} +const VARYING_TYPE_NAMES := { + VisualShader.VARYING_TYPE_FLOAT: "float", + VisualShader.VARYING_TYPE_INT: "int", + VisualShader.VARYING_TYPE_UINT: "uint", + VisualShader.VARYING_TYPE_VECTOR_2D: "vector2", + VisualShader.VARYING_TYPE_VECTOR_3D: "vector3", + VisualShader.VARYING_TYPE_VECTOR_4D: "vector4", + VisualShader.VARYING_TYPE_BOOLEAN: "boolean", + VisualShader.VARYING_TYPE_TRANSFORM: "transform", +} +## Preserve useful aliases from the original #869 implementation. +const ALIASES := { + "VisualShaderNodeScalarOp": "VisualShaderNodeFloatOp", + "VisualShaderNodeScalarFunc": "VisualShaderNodeFloatFunc", + "VisualShaderNodeVectorConstant": "VisualShaderNodeVec3Constant", + "VisualShaderNodeVectorParameter": "VisualShaderNodeVec3Parameter", + "VisualShaderNodeTime": "VisualShaderNodeInput", + "VisualShaderNodeSin": "VisualShaderNodeFloatFunc", + "VisualShaderNodeCos": "VisualShaderNodeFloatFunc", + "VisualShaderNodeLength": "VisualShaderNodeVectorLen", +} +const IMPLICIT := { + "VisualShaderNodeTime": {"input_name": "time"}, + "VisualShaderNodeSin": {"function": "sin"}, + "VisualShaderNodeCos": {"function": "cos"}, +} +## Only node-authoring properties, never script/resource ownership or arbitrary code. +const PROPERTIES := [ + "constant", "texture", "operator", "function", "op_type", "input_name", + "parameter_name", "varying_name", "varying_type", + "default_value_enabled", "default_value", "qualifier", + "source", "texture_type", "texture_filter", "texture_repeat", + "hint", "hint_range_min", "hint_range_max", "hint_range_step", +] +const UNSUPPORTED_NODES := [ + "VisualShaderNodeOutput", "VisualShaderNodeCustom", + "VisualShaderNodeExpression", "VisualShaderNodeGlobalExpression", +] + + +func create_graph(params: Dictionary) -> Dictionary: + var raw_path: Variant = params.get("resource_path", null) + if not raw_path is String or raw_path.is_empty(): + return _invalid("resource_path must be a nonempty res:// .tres path") + var path: String = raw_path + var path_error: Variant = McpPathValidator.path_error(path, "resource_path", true) + if path_error != null: + return path_error + if path.get_extension().to_lower() != "tres": + return _invalid("resource_path must end in .tres: %s" % path) + var overwrite: Variant = params.get("overwrite", false) + if not overwrite is bool: + return _invalid("overwrite must be a boolean") + if FileAccess.file_exists(path) and not overwrite: + return _invalid("Resource already exists at %s (pass overwrite=true to replace)" % path) + var mode: Variant = params.get("shader_type", "spatial") + if not mode is String or not MODES.has(mode): + return _invalid("Unknown shader_type: %s" % str(mode)) + var stages: Variant = params.get("stages", null) + if not stages is Array or stages.is_empty() or stages.size() > MODE_STAGES[mode].size(): + return _invalid("stages must be a nonempty array of applicable, unique shader stages") + var varyings := _parse_varyings(params.get("varyings", []), mode) + if varyings.has("error"): + return varyings + var count_nodes := 0 + var count_connections := 0 + var seen := {} + for stage in stages: + if not stage is Dictionary or not stage.get("stage") is String: + return _invalid("Each stages entry requires a stage name") + var name: String = stage.stage + if not name in MODE_STAGES[mode] or seen.has(name): + return _invalid("Duplicate or incompatible stage '%s' for %s" % [name, mode]) + seen[name] = true + if not stage.get("nodes") is Array or not stage.get("connections") is Array: + return _invalid("Stage %s requires nodes and connections arrays" % name) + count_nodes += stage.nodes.size() + count_connections += stage.connections.size() + if count_nodes > MAX_NODES or count_connections > MAX_CONNECTIONS: + return _invalid("Graph has %d nodes and %d connections; limits are %d and %d" % [count_nodes, count_connections, MAX_NODES, MAX_CONNECTIONS]) + var shader := VisualShader.new() + shader.set_mode(MODES[mode]) + var maps := {} + for stage in stages: + var built := _build_stage(shader, stage) + if built.has("error"): + return built + maps[stage.stage] = built.id_map + for varying in varyings.specs: + shader.add_varying(varying.name, varying.mode, varying.type) + var saved := _save_atomic(shader, path, overwrite) + if saved.has("error"): + return saved + return {"data": { + "resource_path": path, "shader_type": mode, "id_map": maps, + "node_count": count_nodes, "connection_count": count_connections, + "varyings": varyings.specs.map(func(spec): return spec.name), + "undoable": false, "reason": "Creating or replacing a resource file is not undoable; assign the material separately.", + }} + + +func get_graph(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var path_error: Variant = McpPathValidator.path_error(path, "path") + if path_error != null: + return path_error + if not FileAccess.file_exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "VisualShader not found: %s" % path) + var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + if not (loaded is VisualShader): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a VisualShader" % path) + var shader := loaded as VisualShader + var mode := str(MODE_NAMES.get(shader.get_mode(), "")) + var stages: Array[Dictionary] = [] + var node_count := 0 + var connection_count := 0 + for stage_name in MODE_STAGES.get(mode, []): + var stage: int = STAGES[stage_name] + var nodes: Array[Dictionary] = [] + for node_id in shader.get_node_list(stage): + if node_id == 0: + continue + var node := shader.get_node(stage, node_id) + if node == null: + continue + var position := shader.get_node_position(stage, node_id) + nodes.append({ + "id": node_id, + "type": node.get_class(), + "position": {"x": position.x, "y": position.y}, + "params": _serialize_node_params(node), + }) + var connections: Array[Dictionary] = [] + for connection in shader.get_node_connections(stage): + connections.append({ + "from_node": int(connection.get("from_node", -1)), + "from_port": int(connection.get("from_port", -1)), + "to_node": int(connection.get("to_node", -1)), + "to_port": int(connection.get("to_port", -1)), + }) + node_count += nodes.size() + connection_count += connections.size() + if nodes.is_empty() and connections.is_empty(): + continue + stages.append({ + "stage": stage_name, "nodes": nodes, "connections": connections, + }) + return {"data": { + "path": path, "shader_type": mode, "stages": stages, + "node_count": node_count, "connection_count": connection_count, + "varyings": _read_varyings(shader, path), + }} + + +func node_catalog(params: Dictionary) -> Dictionary: + var filter: String = str(params.get("filter", "")).to_lower() + var offset: int = max(0, int(params.get("offset", 0))) + var limit: int = clampi(int(params.get("limit", 100)), 1, MAX_CATALOG_LIMIT) + var matches: Array[String] = [] + for class_name_value in ClassDB.get_class_list(): + var class_name_string := str(class_name_value) + if not filter.is_empty() and not class_name_string.to_lower().contains(filter): + continue + if class_name_string in UNSUPPORTED_NODES: + continue + if not ClassDB.can_instantiate(class_name_string): + continue + if not ClassDB.is_parent_class(class_name_string, "VisualShaderNode"): + continue + matches.append(class_name_string) + matches.sort() + var total := matches.size() + var page: Array[Dictionary] = [] + var end := mini(total, offset + limit) + for i in range(offset, end): + var class_name_string: String = matches[i] + var node: VisualShaderNode = ClassDB.instantiate(class_name_string) + var supported: Array[String] = [] + if node != null: + var present := {} + for property in node.get_property_list(): + present[str(property.name)] = property + for property_name in PROPERTIES: + if not present.has(property_name): + continue + var property: Dictionary = present[property_name] + if int(property.usage) & PROPERTY_USAGE_READ_ONLY or not (int(property.usage) & PROPERTY_USAGE_STORAGE): + continue + supported.append(property_name) + page.append({"type": class_name_string, "params": supported}) + return {"data": { + "nodes": page, "count": page.size(), "total": total, + "offset": offset, "limit": limit, "aliases": ALIASES, + }} + + +func edit_graph(params: Dictionary) -> Dictionary: + var path: String = params.get("resource_path", "") + var path_error: Variant = McpPathValidator.path_error(path, "resource_path", true) + if path_error != null: + return path_error + if path.get_extension().to_lower() != "tres": + return _invalid("resource_path must end in .tres: %s" % path) + if not FileAccess.file_exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "VisualShader not found: %s" % path) + var operations: Variant = params.get("operations", null) + if not operations is Array or operations.is_empty(): + return _invalid("operations must be a nonempty array") + if operations.size() > MAX_OPERATIONS: + return _invalid("operations exceeds the %d-entry limit" % MAX_OPERATIONS) + var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + if not (loaded is VisualShader): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a VisualShader" % path) + var shader := loaded as VisualShader + var mode := str(MODE_NAMES.get(shader.get_mode(), "")) + + ## Alias table for IDs introduced by this call: string ids are resolved to + ## their allocated integer so later operations can reference them. + var aliases := {} + var added: Array[Dictionary] = [] + var inputs := _collect_inputs(shader, mode) + for i in operations.size(): + var operation: Variant = operations[i] + if not operation is Dictionary or not operation.get("op") is String: + return _invalid("operations[%d] requires an op name" % i) + var applied := _apply_edit(shader, mode, operation, aliases, inputs) + if applied.has("error"): + return ErrorCodes.prefix_message(applied, "operations[%d] (%s)" % [i, operation.op]) + if applied.has("added"): + added.append(applied.added) + + var total_nodes := 0 + var total_connections := 0 + for stage_name in MODE_STAGES.get(mode, []): + var stage: int = STAGES[stage_name] + for node_id in shader.get_node_list(stage): + if node_id != 0: + total_nodes += 1 + total_connections += shader.get_node_connections(stage).size() + if total_nodes > MAX_NODES or total_connections > MAX_CONNECTIONS: + return _invalid("Resulting graph has %d nodes and %d connections; limits are %d and %d" % [total_nodes, total_connections, MAX_NODES, MAX_CONNECTIONS]) + + var saved := _save_atomic(shader, path, true) + if saved.has("error"): + return saved + return {"data": { + "resource_path": path, + "operations_applied": operations.size(), + "added": added, + "node_count": total_nodes, + "connection_count": total_connections, + "varyings": _read_varyings(shader, path), + "undoable": false, + "reason": "Editing a resource file is not undoable; assign the material separately.", + }} + + +## Apply one edit operation to `shader` in place. Returns {} on success (plus +## `added` for add_node) or an error dict. `aliases` maps this call's string +## ids to allocated integers; `inputs` tracks occupied (node, port) inputs. +func _apply_edit( + shader: VisualShader, mode: String, operation: Dictionary, + aliases: Dictionary, inputs: Dictionary, +) -> Dictionary: + match operation.op: + "add_node": + return _edit_add_node(shader, mode, operation, aliases) + "remove_node": + var stage := _edit_stage(operation, mode) + if stage.has("error"): + return stage + var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) + if node_id.has("error"): + return node_id + if node_id.value < 2: + return _invalid("cannot remove the built-in output node") + shader.remove_node(stage.value, node_id.value) + inputs.clear() + inputs.merge(_collect_inputs(shader, mode)) + return {} + "replace_node": + var stage := _edit_stage(operation, mode) + if stage.has("error"): + return stage + var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) + if node_id.has("error"): + return node_id + if node_id.value < 2: + return _invalid("cannot replace the built-in output node") + var class_name_value: Variant = operation.get("type") + if not class_name_value is String: + return _invalid("replace_node requires a type") + var real_type := _resolve_type(class_name_value) + if real_type.is_empty(): + return _invalid("%s is not a supported VisualShaderNode" % str(class_name_value)) + var values: Variant = operation.get("params", {}) + if not values is Dictionary: + return _invalid("params must be an object") + var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate() + merged.merge(values, true) + if ClassDB.is_parent_class(real_type, "VisualShaderNodeInput"): + ## Engine gap: `VisualShader.replace_node` swaps the class but never + ## wires `shader_mode`/`shader_type` on the new input node (only + ## `add_node` does), so `input_name` reads as unavailable and codegen + ## is wrong until a save+reload. Rebuild the node at the same id and + ## restore its edges after the params land, when the port type is final. + var position := shader.get_node_position(stage.value, node_id.value) + var edges := _node_edges(shader, stage.value, node_id.value) + var input_node: VisualShaderNode = ClassDB.instantiate(real_type) + shader.remove_node(stage.value, node_id.value) + shader.add_node(stage.value, input_node, position, node_id.value) + var input_applied := _apply_properties(input_node, merged, str(operation.id)) + if input_applied.has("error"): + return input_applied + for edge in edges: + var connect_code := shader.connect_nodes( + stage.value, int(edge.from_node), int(edge.from_port), int(edge.to_node), int(edge.to_port) + ) + if connect_code != OK: + return _invalid("node %s cannot keep connection %d:%d -> %d:%d: %s" % [ + str(operation.id), int(edge.from_node), int(edge.from_port), + int(edge.to_node), int(edge.to_port), error_string(connect_code), + ]) + return {} + shader.replace_node(stage.value, node_id.value, real_type) + return _apply_properties(shader.get_node(stage.value, node_id.value), merged, str(operation.id)) + "set_node_params": + var stage := _edit_stage(operation, mode) + if stage.has("error"): + return stage + var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) + if node_id.has("error"): + return node_id + var node := shader.get_node(stage.value, node_id.value) + if node == null: + return _invalid("node %s is unavailable" % str(operation.id)) + var values: Variant = operation.get("params", {}) + if not values is Dictionary or values.is_empty(): + return _invalid("set_node_params requires a nonempty params object") + return _apply_properties(node, values, str(operation.id)) + "set_node_position": + var stage := _edit_stage(operation, mode) + if stage.has("error"): + return stage + var node_id := _edit_node_id(shader, stage.value, operation.get("id"), aliases) + if node_id.has("error"): + return node_id + var position := _typed_value(TYPE_VECTOR2, operation.get("position", null)) + if not position.has("value"): + return _invalid("position requires finite x/y numbers") + shader.set_node_position(stage.value, node_id.value, position.value) + return {} + "connect", "disconnect": + return _edit_connection(shader, mode, operation, aliases, inputs) + "add_varying": + if not VARYING_MODES.has(mode): + return _invalid("varyings are only supported for spatial/canvas_item shaders") + var parsed := _parse_varying(operation, "") + if parsed.has("error"): + return parsed + if shader.has_varying(parsed.name): + return _invalid("varying '%s' already exists" % parsed.name) + shader.add_varying(parsed.name, parsed.mode, parsed.type) + return {} + "remove_varying": + var varying_name: Variant = operation.get("name") + if not varying_name is String or not (varying_name as String).is_valid_identifier(): + return _invalid("remove_varying requires an identifier name") + if not shader.has_varying(varying_name): + return _invalid("varying '%s' does not exist" % varying_name) + shader.remove_varying(varying_name) + return {} + return _invalid("unknown edit op '%s'" % operation.op) + + +func _edit_add_node( + shader: VisualShader, mode: String, operation: Dictionary, aliases: Dictionary, +) -> Dictionary: + var stage_result := _edit_stage(operation, mode) + if stage_result.has("error"): + return stage_result + var stage: int = stage_result.value + var class_name_value: Variant = operation.get("type") + if not class_name_value is String: + return _invalid("add_node requires a type") + var real_type := _resolve_type(class_name_value) + if real_type.is_empty(): + return _invalid("%s is not a supported VisualShaderNode" % str(class_name_value)) + var raw_id: Variant = operation.get("id", null) + var node_id := 0 + var public_id: Variant = null + if raw_id == null: + node_id = shader.get_valid_node_id(stage) + public_id = node_id + else: + var canonical := _canonical_id(raw_id) + if not _valid_id(canonical): + return _invalid("missing, reserved or invalid node ID %s" % str(canonical)) + if canonical is int: + if shader.get_node(stage, canonical) != null or canonical < 2: + return _invalid("node ID %s is already used" % str(canonical)) + node_id = canonical + public_id = canonical + else: + var alias_key := _alias_key(stage, canonical) + if aliases.has(alias_key): + return _invalid("node ID '%s' is already used in this stage" % canonical) + node_id = shader.get_valid_node_id(stage) + aliases[alias_key] = node_id + public_id = canonical + var position := _typed_value(TYPE_VECTOR2, operation.get("position", {"x": 0, "y": 0})) + if not position.has("value"): + return _invalid("position requires finite x/y numbers") + var node: VisualShaderNode = ClassDB.instantiate(real_type) + shader.add_node(stage, node, position.value, node_id) + var values: Variant = operation.get("params", {}) + if not values is Dictionary: + return _invalid("params must be an object") + var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate() + merged.merge(values, true) + var applied := _apply_properties(node, merged, str(public_id)) + if applied.has("error"): + return applied + return {"added": { + "id": public_id, "node_id": node_id, "stage": STAGE_NAMES[stage], + }} + + +func _edit_connection( + shader: VisualShader, mode: String, operation: Dictionary, aliases: Dictionary, inputs: Dictionary, +) -> Dictionary: + var stage_result := _edit_stage(operation, mode) + if stage_result.has("error"): + return stage_result + var stage: int = stage_result.value + var source := _edit_endpoint(shader, stage, operation.get("from_node"), aliases) + if source.has("error"): + return source + var target := _edit_endpoint(shader, stage, operation.get("to_node"), aliases) + if target.has("error"): + return target + var from_port := _integral_number(operation.get("from_port")) + var to_port := _integral_number(operation.get("to_port")) + if from_port == null or to_port == null or from_port < 0 or to_port < 0 or from_port > 64 or to_port > 64: + return _invalid("connection ports must be integers from 0 through 64") + if operation.op == "disconnect": + shader.disconnect_nodes(stage, source.value, from_port, target.value, to_port) + inputs.erase("%d:%d:%d" % [stage, target.value, to_port]) + inputs.erase("%d:%d:%d:%d" % [stage, target.value, to_port, source.value]) + return {} + return _connect_nodes(shader, stage, source.value, from_port, target.value, to_port, inputs) + + +func _edit_stage(operation: Dictionary, mode: String) -> Dictionary: + var stage_name: Variant = operation.get("stage") + if not stage_name is String or not STAGES.has(stage_name): + return _invalid("stage must be one of: %s" % ", ".join(STAGES.keys())) + var allowed: Array = MODE_STAGES.get(mode, []) + if not allowed.has(stage_name): + return _invalid("stage '%s' is not available for %s shaders; use: %s" % [stage_name, mode, ", ".join(allowed)]) + return {"value": STAGES[stage_name]} + + +func _edit_node_id(shader: VisualShader, stage: int, raw: Variant, aliases: Dictionary) -> Dictionary: + var canonical := _canonical_id(raw) + if canonical is String: + if canonical == "output": + return {"value": 0} + var alias_key := _alias_key(stage, canonical) + if not aliases.has(alias_key): + return _invalid("unknown node ID %s" % canonical) + return {"value": int(aliases[alias_key])} + var number := _integral_number(canonical) + if number == null: + return _invalid("node id must be an integer or a string used by add_node in this call") + if number == 0: + return {"value": 0} + if shader.get_node(stage, number) == null: + return _invalid("node ID %d does not exist in this stage" % number) + return {"value": number} + + +func _edit_endpoint(shader: VisualShader, stage: int, raw: Variant, aliases: Dictionary) -> Dictionary: + var canonical := _canonical_id(raw) + if (canonical is String and canonical == "output") or (canonical is int and canonical == 0): + return {"value": 0} + if canonical is String: + var alias_key := _alias_key(stage, canonical) + if not aliases.has(alias_key): + return _invalid("unknown node ID %s" % canonical) + return {"value": int(aliases[alias_key])} + var number := _integral_number(canonical) + if number == null: + return _invalid("connection endpoints must be node IDs") + if shader.get_node(stage, number) == null: + return _invalid("node ID %d does not exist in this stage" % number) + return {"value": number} + + +## Snapshot of occupied input ports per stage, keyed stage:target:port:source +## so a disconnect can release the exact edge and connect can reject duplicates. +func _collect_inputs(shader: VisualShader, mode: String) -> Dictionary: + var inputs := {} + for stage_name in MODE_STAGES.get(mode, []): + var stage: int = STAGES[stage_name] + for connection in shader.get_node_connections(stage): + var target := int(connection.get("to_node", -1)) + var to_port := int(connection.get("to_port", -1)) + var source := int(connection.get("from_node", -1)) + inputs["%d:%d:%d" % [stage, target, to_port]] = true + inputs["%d:%d:%d:%d" % [stage, target, to_port, source]] = true + return inputs + + +## Every edge touching `node_id` in one stage, captured before a node rebuild +## drops them so the replacement can restore the same connections. +static func _node_edges(shader: VisualShader, stage: int, node_id: int) -> Array[Dictionary]: + var edges: Array[Dictionary] = [] + for connection in shader.get_node_connections(stage): + if int(connection.get("from_node", -1)) == node_id or int(connection.get("to_node", -1)) == node_id: + edges.append(connection) + return edges + + +func _build_stage(shader: VisualShader, spec: Dictionary) -> Dictionary: + var stage: int = STAGES[spec.stage] + var ids := {} + var used := {0: true, 1: true} + ## Reserve all explicit integers before allocating any string ID. + for node_spec in spec.nodes: + if not node_spec is Dictionary: + return _invalid("Stage %s: every node must be an object" % spec.stage) + var raw: Variant = _canonical_id(node_spec.get("id")) + if not _valid_id(raw) or ids.has(raw): + return _invalid("Stage %s: missing, reserved or duplicate node ID %s" % [spec.stage, str(raw)]) + ids[raw] = -1 + if raw is int: + used[raw] = true + ids[raw] = raw + var next_id := 2 + for raw in ids: + if raw is String: + while used.has(next_id): + next_id += 1 + ids[raw] = next_id + used[next_id] = true + var public_ids := [] + for node_spec in spec.nodes: + var raw: Variant = _canonical_id(node_spec.id) + var id: int = ids[raw] + var class_name_value: Variant = node_spec.get("type") + if not class_name_value is String: + return _invalid("Node %s requires a VisualShaderNode type" % str(raw)) + var real_type: String = ALIASES.get(class_name_value, class_name_value) + if not ClassDB.class_exists(real_type) or not ClassDB.is_parent_class(real_type, "VisualShaderNode") or not ClassDB.can_instantiate(real_type): + return _invalid("Node %s: %s is not an instantiable VisualShaderNode" % [str(raw), real_type]) + if real_type in UNSUPPORTED_NODES: + return _invalid("Node %s: %s is not supported in declarative graphs" % [str(raw), real_type]) + var position_value: Variant = node_spec.get("position", {"x": 0, "y": 0}) + var position_result := _typed_value(TYPE_VECTOR2, position_value) + if not position_result.has("value"): + return _invalid("Node %s: position requires finite x/y numbers" % str(raw)) + var node: VisualShaderNode = ClassDB.instantiate(real_type) + shader.add_node(stage, node, position_result.value, id) + var values: Variant = node_spec.get("params", {}) + if not values is Dictionary: + return _invalid("Node %s: params must be an object" % str(raw)) + var merged: Dictionary = IMPLICIT.get(class_name_value, {}).duplicate() + merged.merge(values, true) + var applied := _apply_properties(node, merged, str(raw)) + if applied.has("error"): + return applied + public_ids.append({"id": raw, "node_id": id}) + var inputs := {} + for edge in spec.connections: + if not edge is Dictionary or not edge.has_all(["from_node", "from_port", "to_node", "to_port"]): + return _invalid("Stage %s: connection requires from_node/from_port/to_node/to_port" % spec.stage) + if edge.has("stage") or edge.has("from_stage") or edge.has("to_stage"): + return _invalid("Connections belong to their enclosing stage; cross-stage edges are unsupported") + var source := _endpoint(edge.from_node, ids) + var target := _endpoint(edge.to_node, ids) + if source < 2 or target < 0: + return _invalid("Stage %s: unknown or invalid connection endpoints %s -> %s" % [spec.stage, str(edge.from_node), str(edge.to_node)]) + var from_port_value := _integral_number(edge.from_port) + var to_port_value := _integral_number(edge.to_port) + if from_port_value == null or to_port_value == null or from_port_value < 0 or to_port_value < 0: + return _invalid("Connection ports must be nonnegative integers") + var connected := _connect_nodes(shader, stage, source, from_port_value, target, to_port_value, inputs) + if connected.has("error"): + return _invalid("Stage %s: %s" % [spec.stage, connected.error.message]) + return {"id_map": public_ids} + + +## Validate and create one connection. `inputs` maps `stage:target:port` to +## true; the caller owns the dict so duplicate-input detection spans a whole +## graph build or edit call. +func _connect_nodes( + shader: VisualShader, stage: int, source: int, from_port: int, + target: int, to_port: int, inputs: Dictionary, +) -> Dictionary: + var source_node := shader.get_node(stage, source) + if from_port > 64 or to_port > 64: + return _invalid("Connection port exceeds the supported maximum of 64") + if source_node == null: + return _invalid("source node %d is unavailable" % source) + ## This hidden Array names base output ports whose vector components are + ## exposed. Expand only existing base ports needed to reach a component. + var raw_expanded: Variant = source_node.get("expanded_output_ports") + var expanded: Array = raw_expanded if raw_expanded is Array else Array(raw_expanded) + for port in range(from_port): + if not expanded.has(port): + expanded.append(port) + expanded.sort() + source_node.set("expanded_output_ports", expanded) + var input_key := "%d:%d:%d" % [stage, target, to_port] + if inputs.has(input_key) or not shader.can_connect_nodes(stage, source, from_port, target, to_port): + return _invalid("invalid, duplicate-input or cyclic connection") + var err := shader.connect_nodes(stage, source, from_port, target, to_port) + if err != OK: + return _invalid("cannot connect: %s" % error_string(err)) + inputs[input_key] = true + return {} + + +## Parse a top-level `varyings` array for create_graph. Returns +## {specs: [{name, mode, type}]} or an error dict. +func _parse_varyings(raw: Variant, mode: String) -> Dictionary: + if not raw is Array: + return _invalid("varyings must be an array") + if raw.is_empty(): + return {"specs": []} + if not VARYING_MODES.has(mode): + return _invalid("varyings are only supported for spatial/canvas_item shaders") + var specs: Array[Dictionary] = [] + var seen := {} + for entry in raw: + if not entry is Dictionary: + return _invalid("each varying must be an object") + var parsed := _parse_varying(entry, "") + if parsed.has("error"): + return parsed + if seen.has(parsed.name): + return _invalid("duplicate varying '%s'" % parsed.name) + seen[parsed.name] = true + specs.append({"name": parsed.name, "mode": parsed.mode, "type": parsed.type}) + return {"specs": specs} + + +## Parse one {name, mode, type} varying spec. `label` is unused today but keeps +## call sites explicit about which entry failed. +func _parse_varying(entry: Dictionary, _label: String) -> Dictionary: + var varying_name: Variant = entry.get("name") + if not varying_name is String or not (varying_name as String).is_valid_identifier(): + return _invalid("varying name must be a shader identifier") + var mode := _enum_lookup(VARYING_MODE_VALUES, entry.get("mode"), "varying_mode_") + if mode == null: + return _invalid("varying mode must be one of: %s" % ", ".join(VARYING_MODE_VALUES.keys())) + var varying_type := _enum_lookup(VARYING_TYPE_VALUES, entry.get("type"), "varying_type_") + if varying_type == null: + return _invalid("varying type must be one of: %s" % ", ".join(VARYING_TYPE_VALUES.keys())) + return {"name": varying_name, "mode": mode, "type": varying_type} + + +## Resolve a friendly or engine enum name / raw integer against `table`. +static func _enum_lookup(table: Dictionary, value: Variant, prefix: String) -> Variant: + if value is int: + return value if table.values().has(value) else null + if not value is String: + return null + var normalized: String = value.to_lower().replace(prefix, "").replace("_", "") + for key in table: + if key.replace("_", "") == normalized: + return table[key] + return null + + +## Report a VisualShader's varyings. The engine exposes varying names through +## dynamic `varyings/` properties but no mode/type getters, so the +## serialized resource text carries the `","` values; fall back to +## names only when a value cannot be read. +func _read_varyings(shader: VisualShader, path: String) -> Array[Dictionary]: + var names: Array[String] = [] + for property in shader.get_property_list(): + var property_name := str(property.name) + if property_name.begins_with("varyings/"): + names.append(property_name.substr("varyings/".length())) + names.sort() + var text := "" + if not names.is_empty(): + var file := FileAccess.open(path, FileAccess.READ) + if file != null: + text = file.get_as_text() + file.close() + var out: Array[Dictionary] = [] + for varying_name in names: + var entry := {"name": varying_name} + var raw: Variant = shader.get("varyings/" + varying_name) + if raw is String: + var parts: PackedStringArray = (raw as String).split(",") + if parts.size() == 2: + entry["mode"] = VARYING_MODE_NAMES.get(int(parts[0]), "") + entry["type"] = VARYING_TYPE_NAMES.get(int(parts[1]), "") + if not entry.has("mode"): + var from_text := _varying_from_text(text, varying_name) + if not from_text.is_empty(): + var parts: PackedStringArray = from_text.split(",") + if parts.size() == 2: + entry["mode"] = VARYING_MODE_NAMES.get(int(parts[0]), "") + entry["type"] = VARYING_TYPE_NAMES.get(int(parts[1]), "") + out.append(entry) + return out + + +static func _varying_from_text(text: String, varying_name: String) -> String: + var needle := "varyings/%s" % varying_name + for raw_line in text.split("\n"): + var line := raw_line.strip_edges() + var eq := line.find("=") + if eq < 0: + continue + var left := line.substr(0, eq).strip_edges().trim_prefix("\"").trim_suffix("\"") + if left != needle: + continue + var value := line.substr(eq + 1).strip_edges() + return value.trim_prefix("\"").trim_suffix("\"") + return "" + + +func _serialize_node_params(node: VisualShaderNode) -> Dictionary: + var present := {} + for property in node.get_property_list(): + present[str(property.name)] = property + var params := {} + for property_name in PROPERTIES: + if not present.has(property_name): + continue + var property: Dictionary = present[property_name] + if not (int(property.usage) & PROPERTY_USAGE_STORAGE): + continue + params[property_name] = MaterialValues.serialize_value(node.get(property_name)) + return params + + +static func _resolve_type(value: String) -> String: + var real_type: String = ALIASES.get(value, value) + if not ClassDB.class_exists(real_type) or not ClassDB.is_parent_class(real_type, "VisualShaderNode") or not ClassDB.can_instantiate(real_type): + return "" + if real_type in UNSUPPORTED_NODES: + return "" + return real_type + + +static func _valid_id(value: Variant) -> bool: + return (value is int and value >= 2 and value <= 2147483647) or (value is String and not value.is_empty() and value != "output") + + +static func _canonical_id(value: Variant) -> Variant: + if value is String: + return value + var number := _integral_number(value) + return number if number != null else value + + +## Aliases are scoped to the stage that introduced them: the same string id in +## two stages is two different nodes, and a duplicate inside one stage is +## ambiguous, so registration rejects it instead of overwriting. +static func _alias_key(stage: int, canonical: String) -> String: + return "%d:%s" % [stage, canonical] + + +static func _endpoint(value: Variant, ids: Dictionary) -> int: + value = _canonical_id(value) + if (value is String and value == "output") or (value is int and value == 0): + return 0 + return int(ids.get(value, -1)) if value is int or value is String else -1 + + +func _apply_properties(node: VisualShaderNode, values: Dictionary, id: String) -> Dictionary: + if node == null: + return _invalid("node %s is unavailable" % id) + var properties := {} + for property in node.get_property_list(): + properties[str(property.name)] = property + for key in values: + if not key is String or not key in PROPERTIES or not properties.has(key): + return _invalid("Node %s (%s): unsupported property %s" % [id, node.get_class(), str(key)]) + var property: Dictionary = properties[key] + if int(property.usage) & PROPERTY_USAGE_READ_ONLY or not (int(property.usage) & PROPERTY_USAGE_STORAGE): + return _invalid("Node %s: property %s is not writable" % [id, key]) + var value: Variant = values[key] + var converted := {} + if key == "texture": + if not value is String: + return _invalid("Node %s: texture requires a resource path" % id) + var path_error: Variant = McpPathValidator.path_error(value, "texture") + if path_error != null: + return path_error + if not ResourceLoader.exists(value): + return _invalid("Node %s: texture not found: %s" % [id, value]) + var texture := ResourceLoader.load(value) + var expected: String = str(property.get("class_name", property.get("hint_string", ""))) + if not (texture is Texture2D or texture is Texture3D) or (not expected.is_empty() and not texture.is_class(expected)): + return _invalid("Node %s: incompatible texture %s (expected %s)" % [id, value, expected]) + converted = {"value": texture} + elif int(property.hint) == PROPERTY_HINT_ENUM and int(property.type) == TYPE_INT: + converted = _enum_value(str(property.hint_string), value) + else: + converted = _typed_value(int(property.type), value) + if not converted.has("value"): + return _invalid("Node %s: invalid %s value %s" % [id, key, str(value)]) + if key == "parameter_name" and (str(converted.value).is_empty() or not str(converted.value).is_valid_identifier()): + return _invalid("Node %s: parameter_name must be a shader identifier" % id) + if key == "varying_name" and (str(converted.value).is_empty() or not str(converted.value).is_valid_identifier()): + return _invalid("Node %s: varying_name must be a shader identifier" % id) + node.set(key, converted.value) + if key == "input_name" and node.get_input_real_name().is_empty(): + return _invalid("Node %s: input %s is unavailable in this shader stage" % [id, str(value)]) + return {} + + +static func _enum_value(hint: String, value: Variant) -> Dictionary: + var aliases := {"sub": "subtract", "mul": "multiply", "div": "divide", "mod": "remainder", "pow": "power"} + var normalized := str(aliases.get(value, value)).replace("_", "").replace(" ", "").to_lower() + var index := 0 + for item in hint.split(","): + var parts := item.split(":") + if parts.size() > 1: + index = int(parts[1]) + var numeric := _integral_number(value) + if (numeric != null and numeric == index) or (value is String and parts[0].replace("_", "").replace(" ", "").to_lower() == normalized): + return {"value": index} + index += 1 + return {} + + +static func _typed_value(type: int, value: Variant) -> Dictionary: + match type: + TYPE_BOOL: + return {"value": value} if value is bool else {} + TYPE_INT: + var integer := _integral_number(value) + return {"value": integer} if integer != null else {} + TYPE_FLOAT: + return {"value": float(value)} if _number(value) else {} + TYPE_STRING, TYPE_STRING_NAME: + return {"value": value} if value is String else {} + TYPE_VECTOR2, TYPE_VECTOR3, TYPE_VECTOR4, TYPE_COLOR: + var keys: Array = ["x", "y"] + if type == TYPE_VECTOR3: + keys = ["x", "y", "z"] + elif type == TYPE_VECTOR4: + keys = ["x", "y", "z", "w"] + elif type == TYPE_COLOR: + keys = ["r", "g", "b"] + if not value is Dictionary or not value.has_all(keys): + return {} + for key in keys: + if not _number(value[key]): + return {} + match type: + TYPE_VECTOR2: return {"value": Vector2(value.x, value.y)} + TYPE_VECTOR3: return {"value": Vector3(value.x, value.y, value.z)} + TYPE_VECTOR4: return {"value": Vector4(value.x, value.y, value.z, value.w)} + TYPE_COLOR: + if not _number(value.get("a", 1.0)): + return {} + return {"value": Color(value.r, value.g, value.b, value.get("a", 1.0))} + return {} + + +static func _number(value: Variant) -> bool: + return (value is int or value is float) and is_finite(float(value)) + + +## JSON.parse_string represents every number as float. Accept only values that +## round-trip exactly to an integer; direct GDScript callers may still pass int. +static func _integral_number(value: Variant) -> Variant: + if value is int: + return value + if value is float and is_finite(value) and value == floor(value): + if value >= -2147483648.0 and value <= 2147483647.0: + return int(value) + return null + + +## Parse `code` through the engine's shader compiler and report whether the +## appended sentinel uniform is reflected back. Uniform reflection proves the +## source parsed for the current renderer; it is not a final GPU pipeline +## compile and says nothing about other renderers. +static func _source_parses(code: String, sentinel: String) -> bool: + var probe := Shader.new() + probe.code = code + "\nuniform float %s;\n" % sentinel + for uniform in probe.get_shader_uniform_list(): + if str(uniform.get("name", "")) == sentinel: + return true + return false + + +## The `uniform` and `varying` lines the engine emitted for the graph. Used +## when the current renderer cannot compile the shader type: declarations are +## mode-independent, so they still parse under a type the renderer accepts. +static func _generated_declarations(generated: String) -> String: + var declarations := PackedStringArray() + for line in generated.split("\n"): + var stripped := line.strip_edges() + if stripped.begins_with("uniform ") or stripped.begins_with("varying "): + declarations.append(stripped) + return "\n".join(declarations) + + +## Godot's shader compiler is the authority on declaration legality: a +## parameter named after a shader keyword passes GDScript's +## `is_valid_identifier()` but generates `uniform float float;`, which the +## compiler rejects. Force a copy of the graph to generate its source, then +## parse that source through the engine. The appended sentinel uniform only +## appears in the uniform list when the parsed source is accepted, so a missing +## sentinel means the graph would fail to compile. +## +## Renderers differ in which shader types they compile — the Compatibility +## renderer rejects `shader_type fog` before the graph's content matters — so +## the type is probed first. When the current renderer cannot compile the type, +## the generated declarations are parsed under a supported type instead: the +## advertised modes stay accepted and identifier validation still applies. +static func _validate_generated_graph(shader: VisualShader) -> Dictionary: + var probe := shader.duplicate(true) as VisualShader + if probe == null: + return _invalid("Cannot copy the VisualShader for compile validation") + probe._update_shader() + var generated := probe.get_code() + var sentinel := "_mcp_validate_%d" % Time.get_ticks_usec() + var mode_name := str(MODE_NAMES.get(probe.get_mode(), "")) + if mode_name.is_empty(): + return _invalid("Cannot resolve the VisualShader mode for compile validation") + var source := generated + if not _source_parses("shader_type %s;" % mode_name, sentinel): + source = "shader_type spatial;\n" + _generated_declarations(generated) + if _source_parses(source, sentinel): + return {} + return _invalid( + "The graph's generated shader does not compile; check parameter and varying " + + "identifiers and declarations (reserved shader keywords are not legal names)" + ) + + +## Stage in the destination directory, then use the OS rename/replace primitive. +## A failed save or rename never removes/truncates the existing destination. +## The generated shader is compile-checked first, so an uncompilable graph is +## rejected before the destination is created or replaced. +func _save_atomic(shader: VisualShader, path: String, overwrite: bool) -> Dictionary: + var compiled := _validate_generated_graph(shader) + if compiled.has("error"): + return compiled + var directory := path.get_base_dir() + if not DirAccess.dir_exists_absolute(directory): + return _invalid("Destination directory does not exist: %s" % directory) + var temporary := directory.path_join(".godot-ai-shader-%d-%d.tres" % [OS.get_process_id(), Time.get_ticks_usec()]) + var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID + var err := ResourceSaver.save(shader, temporary) + if err == OK and prior_uid != ResourceUID.INVALID_ID: + err = ResourceSaver.set_uid(temporary, prior_uid) + if err == OK and FileAccess.file_exists(path) and not overwrite: + err = ERR_ALREADY_EXISTS + if err == OK: + err = DirAccess.rename_absolute(temporary, path) + if err != OK: + if FileAccess.file_exists(temporary): + DirAccess.remove_absolute(temporary) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot save VisualShader at %s: %s" % [path, error_string(err)]) + shader.take_over_path(path) + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem != null: + filesystem.update_file(path) + return {} + + +static func _invalid(message: String) -> Dictionary: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message) diff --git a/addons/godot_ai/handlers/visual_shader_handler.gd.uid b/addons/godot_ai/handlers/visual_shader_handler.gd.uid new file mode 100644 index 0000000..6f3b43f --- /dev/null +++ b/addons/godot_ai/handlers/visual_shader_handler.gd.uid @@ -0,0 +1 @@ +uid://c6n50c5gk5fit diff --git a/addons/godot_ai/mcp_dock.gd b/addons/godot_ai/mcp_dock.gd index 1d90189..5157457 100644 --- a/addons/godot_ai/mcp_dock.gd +++ b/addons/godot_ai/mcp_dock.gd @@ -24,6 +24,7 @@ extends VBoxContainer 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 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 ClientRegistry := preload("res://addons/godot_ai/clients/_registry.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 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 status_snapshot_requested signal live_server_probe_requested(port: int) @@ -457,7 +459,7 @@ func _build_ui() -> void: _crash_docs_btn = Button.new() _crash_docs_btn.text = "How to change the port" - _crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients" + _crash_docs_btn.tooltip_text = "Open the guide: change ports in Godot AI settings and reconfigure your MCP clients" _crash_docs_btn.visible = false _crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url())) _crash_panel.add_child(_crash_docs_btn) @@ -720,13 +722,13 @@ func _build_client_row(client_id: String) -> void: var configure_btn := Button.new() 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) var remove_btn := Button.new() remove_btn.text = "Remove" 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) # 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)): status_text = "Recovering after update…" 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: var exit_ms: int = server_status.get("exit_ms", 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)) if conflict_port <= 0: 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 elif state == ServerStateScript.NO_COMMAND: 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?". var port := ClientConfigurator.http_port() 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: 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: @@ -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 per-client reconfigure steps live behind the crash panel's docs link. static func _free_port_hint(port: int) -> String: - var free_http := ClientConfigurator.suggest_free_port(port + 1) - var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1) + var occupancy := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {} + 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] @@ -1745,8 +1762,13 @@ func _on_configure_client(client_id: String) -> void: _dispatch_client_action(client_id, "configure") -func _on_remove_client(client_id: String) -> void: - _dispatch_client_action(client_id, "remove") +func _on_client_action_button_pressed(client_id: String, action: String) -> void: + 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. @@ -1771,6 +1793,12 @@ func present_client_action_result( ) -> void: _report_prewarm_outcome(client_id, prewarm) _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 if result.get("status") == "ok": ## #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) if busy.has(id): _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…" else: _finalize_action_buttons(id) @@ -2335,9 +2367,19 @@ func _build_settings_tab(tabs: TabContainer) -> void: ## `_reset_tools_pending_from_setting` / `_on_open_clients_window`). var settings_tab := VBoxContainer.new() 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() settings_margin.name = "Settings" - settings_margin.add_child(settings_tab) + settings_margin.add_child(settings_scroll) tabs.add_child(settings_margin) ## 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: if _allow_hosts_edit == null or _allow_hosts_apply_btn == null: return - var invalid := McpAllowHosts.invalid_tokens(_allow_hosts_edit.text) - if invalid.is_empty(): - _allow_hosts_hint.visible = false - else: - ## 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() + var error := McpAllowHosts.configuration_error(_allow_hosts_edit.text) + _allow_hosts_hint.text = error + _allow_hosts_hint.visible = not error.is_empty() + _allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not error.is_empty() func _on_allow_hosts_apply() -> void: if _allow_hosts_edit == null: return 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 _allow_hosts_saved = normalized _allow_hosts_edit.text = normalized diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg index 5657942..a7ba07d 100644 --- a/addons/godot_ai/plugin.cfg +++ b/addons/godot_ai/plugin.cfg @@ -3,5 +3,5 @@ name="Godot AI" description="MCP server and AI tools for Godot" author="Godot AI" -version="4.1.0" +version="4.2.1" script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd index 77f617c..93a7ffe 100644 --- a/addons/godot_ai/plugin.gd +++ b/addons/godot_ai/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("animation", HANDLERS_DIR + "animation_handler.gd", [undo]) _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("camera", HANDLERS_DIR + "camera_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("tileset", HANDLERS_DIR + "tileset_handler.gd", []) _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("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("search_resources", "resource", &"search_resources") _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("create_resource", "resource", &"create_resource") _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("reimport", "filesystem", &"reimport") _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("connect_signal", "signal", &"connect_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("set_anchor_preset", "ui", &"set_anchor_preset") _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("create_theme", "theme", &"create_theme") _dispatcher.register_lazy("theme_set_color", "theme", &"set_color") _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_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("animation_player_create", "animation", &"create_player") _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_pulse", "animation", &"preset_pulse") _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_shader_param", "material", &"set_shader_param") _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_apply_to_node", "material", &"apply_to_node") _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_set_main", "particle", &"set_main") _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_get_used_cells", "gridmap", &"get_used_cells") _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_set_operation", "csg", &"set_operation") @@ -545,6 +567,7 @@ func _continue_enter_tree_after_update_barrier() -> void: _dock.name = "Godot AI" _dock.update_requested.connect(_on_dock_update_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.status_snapshot_requested.connect(_on_dock_status_snapshot_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. func _activate_startup_endpoints() -> void: 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( str(_post_update_outcome.get("from_version", "")), str(_post_update_outcome.get("to_version", "")), ) + _startup_trace_call_end("prepare_upgrade_endpoints", prepare_started) if not bool(prepared.get("ok", false)): _present_endpoint_setup_failure(str(prepared.get("error", "Endpoint selection failed."))) return + var override_started := _startup_trace_call_begin("endpoint_override") var override := ClientConfigurator.v4_endpoint_ports_status() + _startup_trace_call_end("endpoint_override", override_started) if not bool(override.get("ok", false)): _present_endpoint_setup_failure(str(override.get("error", "Invalid endpoint override."))) return + var policy_started := _startup_trace_call_begin("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 configured_ws := int(resolved_policy.ws_port) 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]) return + var ws_started := _startup_trace_call_begin("resolve_ws_port") var resolved_ws := _resolve_ws_port(configured_ws) + _startup_trace_call_end("resolve_ws_port", ws_started) if ( resolved_ws < ClientConfigurator.MIN_PORT or resolved_ws > ClientConfigurator.MAX_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.") return 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) + _startup_trace_call_end("capability_path", capability_started) _set_endpoint_policy(resolved_policy) if _connection != null: _connection.ws_port = resolved_ws @@ -608,9 +641,16 @@ func _activate_startup_endpoints() -> void: if _dock != null: _dock.present_update_state({"post_update_action": "", "status_text": "", "label_text": "", "banner_visible": false}) ## #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) - _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() + _startup_trace_call_end("startup_release", release_started) 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()) +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: if _client_jobs == null: return @@ -1287,6 +1335,26 @@ func _startup_trace_phase(name: String) -> void: _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: if not _startup_trace_enabled: return @@ -1316,19 +1384,30 @@ func _capture_lifecycle_plan() -> Dictionary: var policy := _endpoint_policy.duplicate(true) var http_port := int(policy.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT)) var worktree_src := "" + var worktree_started := _startup_trace_call_begin("worktree_source") if ClientConfigurator.is_dev_checkout(): worktree_src = ClientConfigurator.find_worktree_src_dir( 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 { "http_port": http_port, "capability_path": str(policy.get("capability_path", "")), "ws_port": int(policy.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)), - "expected_version": ClientConfigurator.get_plugin_version(), - "server_command": ClientConfigurator.get_server_command(), - "pid_file": ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE), - "startup_report": ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT), - "http_port_reserved": WindowsPortReservation.is_port_excluded(http_port), + "expected_version": expected_version, + "server_command": server_command, + "pid_file": pid_file, + "startup_report": startup_report, + "http_port_reserved": http_port_reserved, "excluded_domains": str(policy.get("excluded_domains", "")), "allow_hosts": str(policy.get("allow_hosts", "")), "keep_alive": bool(policy.get("keep_alive", false)), @@ -1744,7 +1823,16 @@ func restart_or_start_managed_server() -> bool: _lifecycle.force_restart_server() return true 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( "MCP | refusing to restart the unowned server on port %d; stop it from its launcher" % port diff --git a/addons/godot_ai/runtime/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd index 85d9c23..1859fb3 100644 --- a/addons/godot_ai/runtime/game_helper.gd +++ b/addons/godot_ai/runtime/game_helper.gd @@ -343,7 +343,7 @@ func _capture_and_reply( _reply_error(request_id, "Captured an empty image from game viewport") 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 stale := frames_drawn <= frames_at_request diff --git a/addons/godot_ai/tool_catalog.gd b/addons/godot_ai/tool_catalog.gd index d1e8b4a..420ddb6 100644 --- a/addons/godot_ai/tool_catalog.gd +++ b/addons/godot_ai/tool_catalog.gd @@ -50,6 +50,7 @@ const DOMAINS := [ {"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]}, {"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_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": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]}, {"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]}, diff --git a/addons/godot_ai/utils/allow_hosts.gd b/addons/godot_ai/utils/allow_hosts.gd index a34c964..a1ca857 100644 --- a/addons/godot_ai/utils/allow_hosts.gd +++ b/addons/godot_ai/utils/allow_hosts.gd @@ -168,3 +168,17 @@ static func _is_private_ipv4(addr: String) -> bool: var second := int(addr.get_slice(".", 1)) return second >= 16 and second <= 31 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." diff --git a/addons/godot_ai/utils/client_job_owner.gd b/addons/godot_ai/utils/client_job_owner.gd index 58ef803..5102ba1 100644 --- a/addons/godot_ai/utils/client_job_owner.gd +++ b/addons/godot_ai/utils/client_job_owner.gd @@ -6,8 +6,8 @@ extends Node ## 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 ## destroying a live Godot Thread corrupts the GDScript VM. This node therefore -## owns both worker pools, their cancellation, phases, and one action slot per -## client until its Thread has been joined. It publishes copied value snapshots +## owns both worker pools, their cancellation, phases, and one mutation slot +## until its Thread has been joined. It publishes copied value snapshots ## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager. const Client := preload("res://addons/godot_ai/clients/_base.gd") @@ -57,6 +57,7 @@ var _refresh_generation := 0 var _mcp_status_waiters: Dictionary = {} var _action_threads: Dictionary = {} +var _pending_actions: Array[Dictionary] = [] var _action_started_msec: Dictionary = {} var _action_names: Dictionary = {} var _action_request_ids: Dictionary = {} @@ -106,7 +107,7 @@ func _ready() -> void: 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 @@ -128,8 +129,15 @@ func set_client_health_blocked(blocked: bool) -> void: func snapshot() -> Dictionary: var busy: Array[String] = [] + var names := _action_names.duplicate(true) + var phases := _read_action_phases() for client_id in _action_threads: 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() return { "accepting_work": _accepting_work, @@ -137,8 +145,8 @@ func snapshot() -> Dictionary: "refresh_completed": _refresh_completed_msec > 0, "post_update_repin_running": _post_update_thread != null, "busy_actions": busy, - "action_names": _action_names.duplicate(true), - "action_phases": _read_action_phases(), + "action_names": names, + "action_phases": phases, } @@ -154,6 +162,7 @@ func _process(_delta: float) -> void: _poll_post_update_repin() _poll_refresh() _poll_actions() + _start_next_action() _check_refresh_timeout() _check_action_timeouts() _retry_deferred_refresh() @@ -166,6 +175,7 @@ func _process(_delta: float) -> void: func quiesce(deadline_msec: int = 0) -> Dictionary: _accepting_work = false _refresh_state = RefreshState.SHUTTING_DOWN + _pending_actions.clear() _publish_snapshot() for client_id in _action_threads: _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."} if action not in ["configure", "remove"]: 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 { "ok": false, "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): 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): 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() _set_action_cancelled(client_id, false) _clear_action_phase(client_id) @@ -631,6 +694,7 @@ func _record_unproven_action_result( return if ( bool(result.get("termination_failed", false)) + or bool(result.get("mutation_lock_release_failed", false)) or bool(prewarm.get("termination_failed", false)) ): _mark_mutation_termination_unproven(client_id, action) diff --git a/addons/godot_ai/utils/json_values.gd b/addons/godot_ai/utils/json_values.gd index 6100836..a21f602 100644 --- a/addons/godot_ai/utils/json_values.gd +++ b/addons/godot_ai/utils/json_values.gd @@ -18,6 +18,17 @@ extends RefCounted const COLOR_KEYS: Array[String] = ["r", "g", "b"] const VECTOR2_KEYS: Array[String] = ["x", "y"] 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: @@ -104,5 +115,235 @@ static func parse_float(value: Variant) -> Variant: 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: 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) diff --git a/addons/godot_ai/utils/mcp_server_state.gd b/addons/godot_ai/utils/mcp_server_state.gd index f127015..2a412f1 100644 --- a/addons/godot_ai/utils/mcp_server_state.gd +++ b/addons/godot_ai/utils/mcp_server_state.gd @@ -14,6 +14,7 @@ const PORT_EXCLUDED := 7 const FOREIGN_PORT := 8 const STOPPING := 10 const STOPPED := 11 +const UNSUPPORTED_CONFIG := 12 const _NAMES := { UNINITIALIZED: "uninitialized", @@ -26,6 +27,7 @@ const _NAMES := { FOREIGN_PORT: "foreign_port", STOPPING: "stopping", STOPPED: "stopped", + UNSUPPORTED_CONFIG: "unsupported_config", } @@ -45,13 +47,14 @@ static func is_terminal_diagnosis(state: int) -> bool: or state == PORT_EXCLUDED or state == INCOMPATIBLE or state == FOREIGN_PORT + or state == UNSUPPORTED_CONFIG ) ## True when the dock should skip interpreting client health (incompatible ## tool surface). This must NOT block Configure writes — those take an ## 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. static func blocks_client_health(state: int) -> bool: - return state == INCOMPATIBLE + return state in [INCOMPATIBLE, UNSUPPORTED_CONFIG] diff --git a/addons/godot_ai/utils/mesh_workload.gd b/addons/godot_ai/utils/mesh_workload.gd new file mode 100644 index 0000000..3f962a7 --- /dev/null +++ b/addons/godot_ai/utils/mesh_workload.gd @@ -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} diff --git a/addons/godot_ai/utils/mesh_workload.gd.uid b/addons/godot_ai/utils/mesh_workload.gd.uid new file mode 100644 index 0000000..835c067 --- /dev/null +++ b/addons/godot_ai/utils/mesh_workload.gd.uid @@ -0,0 +1 @@ +uid://c7rw0m5m0i1x5 diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd index 9ef32e9..cfba5a4 100644 --- a/addons/godot_ai/utils/port_resolver.gd +++ b/addons/godot_ai/utils/port_resolver.gd @@ -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") static var _process_spawn_mutex := Mutex.new() +enum PortOccupancy { UNKNOWN, FREE, OCCUPIED } + ## Serialize plugin-owned subprocess creation across the brief process-global ## 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 -## True when `port` is bound on 127.0.0.1. Probes via TCPServer first, -## falls back to OS scraping. Callers that want per-scraper trace -## counters should call `is_port_in_use_via_scrape` with a trace hook -## after their own `can_bind_local_port` probe. +## Windows permits a loopback bind beside a wildcard listener. Use the OS +## listener table, and treat failed queries as unavailable rather than free. static func is_port_in_use(port: int) -> bool: - if can_bind_local_port(port): - ## On POSIX, an IPv6 wildcard listener can coexist with a - ## 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 + if OS.get_name() == "Windows": + return windows_port_occupancy(port) != PortOccupancy.FREE 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: var output: Array = [] if OS.get_name() == "Windows": - _trace(trace, "netstat") - 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() + return windows_port_occupancy(port, windows_listener_snapshot(trace)) != PortOccupancy.FREE _trace(trace, "lsof") 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(): @@ -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 -## 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. static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: 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 ## through netstat → PowerShell, and a wrapping caller can't see which ## 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": - var output: Array = [] - _trace(trace, "netstat") - var exit_code := OS.execute("netstat", ["-ano"], output, true) - if exit_code == 0 and not output.is_empty(): - var stdout := str(output[0]) - var netstat_pids := parse_windows_netstat_pids(stdout, port) - 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) + if snapshot.is_empty(): + snapshot = windows_listener_snapshot(trace) + var pids: Array[int] = [] + if snapshot.known: + pids.assign(snapshot.listeners.get(port, [])) + return pids var output: Array = [] _trace(trace, "lsof") 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) -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: var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script] for exe in windows_powershell_candidates(): @@ -205,17 +242,6 @@ static func windows_powershell_candidates() -> Array[String]: 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. ## Empty lines, non-positive values, non-numeric tokens, and duplicate ## 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 -## 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 ## `:port` would false-positive against an unrelated ESTABLISHED row. 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]: + var snapshot := windows_snapshot_from_netstat(0, [stdout]) var pids: Array[int] = [] - var port_suffix := ":%d" % port - for line in stdout.split("\n"): - 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) + if snapshot.known: + pids.assign(snapshot.listeners.get(port, [])) 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: - for line in stdout.split("\n"): - 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 + return bool(windows_snapshot_from_netstat(0, [stdout]).known) ## `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. ## 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) - 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: push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) - return + return occupancy OS.delay_msec(100) + return PortOccupancy.UNKNOWN ## Choose a non-Windows-reserved WS port. Returns `configured` when free; diff --git a/addons/godot_ai/utils/resource_inspector.gd b/addons/godot_ai/utils/resource_inspector.gd new file mode 100644 index 0000000..44fe29e --- /dev/null +++ b/addons/godot_ai/utils/resource_inspector.gd @@ -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") diff --git a/addons/godot_ai/utils/resource_inspector.gd.uid b/addons/godot_ai/utils/resource_inspector.gd.uid new file mode 100644 index 0000000..851adff --- /dev/null +++ b/addons/godot_ai/utils/resource_inspector.gd.uid @@ -0,0 +1 @@ +uid://ddbv5xpo41ed3 diff --git a/addons/godot_ai/utils/screenshot_encode.gd b/addons/godot_ai/utils/screenshot_encode.gd index e3b0210..d46c312 100644 --- a/addons/godot_ai/utils/screenshot_encode.gd +++ b/addons/godot_ai/utils/screenshot_encode.gd @@ -14,7 +14,14 @@ extends RefCounted ## Downscale `image` in place so its longest edge is at most ## `max_resolution` (0 = no cap), then PNG-encode. Returns ## {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_height := image.get_height() diff --git a/addons/godot_ai/utils/script_work.gd b/addons/godot_ai/utils/script_work.gd index bb919a4..fc236eb 100644 --- a/addons/godot_ai/utils/script_work.gd +++ b/addons/godot_ai/utils/script_work.gd @@ -20,6 +20,12 @@ static func finish(id: int) -> void: _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: if not _active.is_empty(): return { diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd index b4f1795..903f52b 100644 --- a/addons/godot_ai/utils/server_lifecycle.gd +++ b/addons/godot_ai/utils/server_lifecycle.gd @@ -105,6 +105,11 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void: _cancel_effect() _replacement_authorization = 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: _next_episode_id += 1 existing_id = _next_episode_id @@ -313,6 +318,7 @@ func transport_lost(reason := "Authenticated server endpoint was lost.") -> void _ready_since_msec = 0 var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size() if _endpoint_recovery_attempts >= limit: + _endpoint_recovery_pending_episode = 0 _block( "endpoint_lost", "%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 _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( "endpoint_lost", "%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)" % [reason, int(delay), _endpoint_recovery_attempts, limit] ) - _endpoint_recovery_pending_episode = int(_episode.get("id", 0)) if bool(_plan.get("automatic_effects", true)): _recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode) @@ -700,8 +706,9 @@ func _effect_probe(payload: Dictionary) -> Dictionary: if disposition == "unproven": return _blocked_probe_result("process_authority_mismatch", port, {}, false, "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 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): var version := str(live.get("version", "")) 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), } 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 blocked := _blocked_probe_result("occupied", port, live, false, detail) 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 ## 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. - if expected_ws_port > 0 and PortResolver.is_port_in_use(expected_ws_port): - return ws_port_blocked_result(expected_ws_port) + if expected_ws_port > 0: + 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 { "outcome": "free", "owned_disposition": disposition, @@ -941,7 +961,8 @@ func _effect_prove(payload: Dictionary) -> Dictionary: or str(capability.get("websocket", "")) != str(payload.get("ws_capability", "")) ): 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): return {"pending": true, "reason": "authenticated_status"} 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", ""))) if pid <= 1: 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"} if first_snapshot == null or pid != hinted_pid: first_snapshot = ( @@ -973,13 +994,14 @@ func _effect_prove(payload: Dictionary) -> Dictionary: ## record must still name the authenticated instance, and the process ## fingerprint must remain unchanged after the final probe. 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) if PortResolver.capture_failed(final_snapshot): return {"pending": true, "reason": "identity_unavailable"} + var final_listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {} if ( 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.pid_cmdline_is_godot_ai(pid, final_snapshot) or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint @@ -1085,8 +1107,8 @@ func _effect_replace(payload: Dictionary) -> Dictionary: ) return {"ok": false, "reason": "replacement_failed"} if launch.is_empty(): - PortResolver.wait_for_port_free(port, 5.0) - return {"ok": not PortResolver.is_port_in_use(port), "reason": ""} + var occupancy := PortResolver.wait_for_port_free(port, 5.0) + return {"ok": occupancy == PortResolver.PortOccupancy.FREE, "reason": ""} return {"ok": true, "reason": "", "launch": launch} @@ -1165,13 +1187,14 @@ func _effect_stop(payload: Dictionary) -> Dictionary: if disposition == "owned": grants.append({"pid": pid, "fingerprint": fingerprint}) PortResolver.kill_exact_processes(grants, true) + var occupancy := PortResolver.PortOccupancy.FREE 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 for exact in grants: if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint): 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 ## 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. @@ -1283,7 +1306,7 @@ static func probe_live_server_status( 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 http_capability := str(capability.get("http", "")) 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 ## loopback port. This is only an unreachable probe; callers still check ## 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" return result var client := HTTPClient.new() @@ -1412,7 +1438,9 @@ static func _blocked_probe_result( var message := "Port %d is occupied by another process." % port if not detail.is_empty(): 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] return { "outcome": "blocked", @@ -1627,6 +1655,13 @@ func get_status_dict() -> Dictionary: return { "episode_id": int(_episode.get("id", 0)), "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", "")), "ready_kind": str(_episode.get("ready_kind", "")), "state": _dock_state(state, reason), @@ -1713,9 +1748,10 @@ static func _dock_state(state: String, reason: String) -> int: return ServerState.STOPPING BLOCKED: match reason: + "unsupported_remote_access": return ServerState.UNSUPPORTED_CONFIG "incompatible": return ServerState.INCOMPATIBLE "no_command": return ServerState.NO_COMMAND "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.UNINITIALIZED