Initial Void Bastion 0.20 prototype
This commit is contained in:
commit
7362ca0f02
251 files changed
+29688
No files matched your search
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Blake Bukowsky
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Tools
|
||||
|
||||
Godot editor plugin that exposes **43 tools across 12 namespaces** (scene edits, signal wiring, resource creation, reference validation, animation, headless test runs, etc.) over MCP so coding agents can operate through the editor's real APIs instead of hand-editing `.tscn` / `.tres` / `project.godot` as text.
|
||||
|
||||
Works with any MCP-capable client — Claude Code, Claude Desktop, Cursor, Cline, Windsurf, Continue, Zed.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Enable this plugin: *Project → Project Settings → Plugins* → tick **Agent Tools**.
|
||||
2. Confirm the Output panel shows `[agent_tools] listening on 127.0.0.1:9920`.
|
||||
3. Configure your MCP client to run the bridge — see the [full setup guide](https://github.com/BlakeBukowsky/GodotTools#configure-your-agent) for per-client config paths.
|
||||
|
||||
Simplest user-scoped config (for Claude Code, Cursor, Windsurf, etc. — different config file per client):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"godot-agent-tools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "godot-agent-tools-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Full documentation, troubleshooting, and tool catalog: **https://github.com/BlakeBukowsky/GodotTools**
|
||||
|
||||
## Raw TCP protocol (if you want to skip MCP)
|
||||
|
||||
Line-delimited JSON-RPC on `127.0.0.1:9920`:
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "scene.current", "params": {}}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"id": 1, "result": {"open": false}}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,187 @@
|
||||
# Auto-run driver for run.scene_headless (input simulation, multi-screenshot,
|
||||
# state dump, deterministic seed). Reads a per-call config from
|
||||
# res://.godot/agent_tools/headless/config.json, instances the target scene,
|
||||
# injects scripted input per frame, captures screenshots at specified frames,
|
||||
# optionally writes a final-state JSON dump, then quits.
|
||||
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
const CONFIG_PATH := "res://.godot/agent_tools/headless/config.json"
|
||||
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
|
||||
|
||||
var _input_script: Array = []
|
||||
var _screenshots: Array = [] # [{frame, path}, ...]
|
||||
var _state_dump: bool = false
|
||||
var _state_dump_path: String = ""
|
||||
var _quit_frame: int = 120
|
||||
var _frame: int = 0
|
||||
var _target_inst: Node = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var f := FileAccess.open(CONFIG_PATH, FileAccess.READ)
|
||||
if f == null:
|
||||
push_error("[headless_driver] config not found at %s" % CONFIG_PATH)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
var parsed = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_error("[headless_driver] malformed config")
|
||||
get_tree().quit(2)
|
||||
return
|
||||
|
||||
# Seed the global RNG before instancing so the target scene's randomness
|
||||
# (randi/randf in _ready, etc.) is deterministic per-seed.
|
||||
if parsed.has("seed"):
|
||||
seed(int(parsed.seed))
|
||||
|
||||
_input_script = parsed.get("input_script", [])
|
||||
_screenshots = parsed.get("screenshots", [])
|
||||
_state_dump = bool(parsed.get("state_dump", false))
|
||||
_state_dump_path = str(parsed.get("state_dump_path", ""))
|
||||
_quit_frame = int(parsed.get("quit_frame", 120))
|
||||
var target_scene_path: String = parsed.get("target_scene", "")
|
||||
|
||||
if target_scene_path == "":
|
||||
push_error("[headless_driver] config missing 'target_scene'")
|
||||
get_tree().quit(2)
|
||||
return
|
||||
var packed = load(target_scene_path)
|
||||
if packed == null:
|
||||
push_error("[headless_driver] failed to load target %s" % target_scene_path)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
_target_inst = packed.instantiate()
|
||||
add_child(_target_inst)
|
||||
print("[headless_driver] driving %s for %d frames, %d events, %d screenshots%s" %
|
||||
[target_scene_path, _quit_frame, _input_script.size(), _screenshots.size(),
|
||||
", state_dump=on" if _state_dump else ""])
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# 1. Inject input events scheduled for this frame.
|
||||
for spec in _input_script:
|
||||
if int(spec.get("frame", -1)) == _frame:
|
||||
_inject(spec)
|
||||
|
||||
# 2. Capture screenshots scheduled for this frame.
|
||||
for shot in _screenshots:
|
||||
if int(shot.get("frame", -1)) == _frame:
|
||||
_capture(str(shot.get("path", "")))
|
||||
|
||||
if _frame >= _quit_frame:
|
||||
if _state_dump and _state_dump_path != "":
|
||||
_write_state_dump()
|
||||
print("[headless_driver] done at frame %d" % _frame)
|
||||
get_tree().quit(0)
|
||||
return
|
||||
_frame += 1
|
||||
|
||||
|
||||
func _inject(spec: Dictionary) -> void:
|
||||
match spec.get("type", ""):
|
||||
"action_press":
|
||||
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
|
||||
"action_release":
|
||||
Input.action_release(spec.get("action", ""))
|
||||
"action_tap":
|
||||
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
|
||||
Input.action_release(spec.get("action", ""))
|
||||
"key":
|
||||
var ek := InputEventKey.new()
|
||||
var kc = spec.get("keycode", 0)
|
||||
if kc is String:
|
||||
ek.physical_keycode = OS.find_keycode_from_string(kc)
|
||||
else:
|
||||
ek.physical_keycode = int(kc)
|
||||
ek.pressed = bool(spec.get("pressed", true))
|
||||
Input.parse_input_event(ek)
|
||||
"mouse_click":
|
||||
var pos = spec.get("position", [0, 0])
|
||||
var v := Vector2(pos[0], pos[1])
|
||||
var button := int(spec.get("button", 1))
|
||||
var down := InputEventMouseButton.new()
|
||||
down.button_index = button
|
||||
down.pressed = true
|
||||
down.position = v
|
||||
down.global_position = v
|
||||
Input.parse_input_event(down)
|
||||
var up := InputEventMouseButton.new()
|
||||
up.button_index = button
|
||||
up.pressed = false
|
||||
up.position = v
|
||||
up.global_position = v
|
||||
Input.parse_input_event(up)
|
||||
"mouse_motion":
|
||||
var pos = spec.get("position", [0, 0])
|
||||
var em := InputEventMouseMotion.new()
|
||||
em.position = Vector2(pos[0], pos[1])
|
||||
em.global_position = em.position
|
||||
Input.parse_input_event(em)
|
||||
_:
|
||||
push_warning("[headless_driver] unknown event type: %s" % spec.get("type", "<missing>"))
|
||||
|
||||
|
||||
func _capture(path: String) -> void:
|
||||
if path == "":
|
||||
return
|
||||
var vp := get_tree().root.get_viewport()
|
||||
if vp == null:
|
||||
push_warning("[headless_driver] no root viewport")
|
||||
return
|
||||
var tex := vp.get_texture()
|
||||
if tex == null:
|
||||
push_warning("[headless_driver] viewport has no texture (running under dummy renderer?)")
|
||||
return
|
||||
var img := tex.get_image()
|
||||
if img == null or img.is_empty():
|
||||
push_warning("[headless_driver] failed to capture image at frame %d" % _frame)
|
||||
return
|
||||
var dir := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir):
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var err := img.save_png(path)
|
||||
if err != OK:
|
||||
push_warning("[headless_driver] save_png failed: %d" % err)
|
||||
else:
|
||||
print("[headless_driver] frame %d -> %s" % [_frame, path])
|
||||
|
||||
|
||||
func _write_state_dump() -> void:
|
||||
if _target_inst == null:
|
||||
return
|
||||
var snapshot := _node_snapshot(_target_inst, _target_inst)
|
||||
var f := FileAccess.open(_state_dump_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_warning("[headless_driver] failed to open state dump path: %s" % _state_dump_path)
|
||||
return
|
||||
f.store_string(JSON.stringify(snapshot, " "))
|
||||
f.close()
|
||||
print("[headless_driver] state dump -> %s" % _state_dump_path)
|
||||
|
||||
|
||||
# Mirrors scene.inspect's shape, plus a small selection of commonly-useful
|
||||
# properties. Kept narrow on purpose — full get_property_list() dumps balloon.
|
||||
static func _node_snapshot(node: Node, scene_root: Node) -> Dictionary:
|
||||
var d := {
|
||||
"name": String(node.name),
|
||||
"class": node.get_class(),
|
||||
"node_path": "." if node == scene_root else String(scene_root.get_path_to(node)),
|
||||
"script": "",
|
||||
"properties": {},
|
||||
"children": [],
|
||||
}
|
||||
var scr := node.get_script() as Script
|
||||
if scr:
|
||||
d.script = scr.resource_path
|
||||
# Common props worth verifying, if present on this node type.
|
||||
for prop_name in ["visible", "position", "global_position", "size", "text",
|
||||
"value", "modulate", "color", "rotation", "scale", "disabled", "pressed"]:
|
||||
if prop_name in node:
|
||||
d.properties[prop_name] = Coerce.to_json(node.get(prop_name))
|
||||
for child in node.get_children():
|
||||
if child.owner == scene_root or child == scene_root:
|
||||
d.children.append(_node_snapshot(child, scene_root))
|
||||
return d
|
||||
@@ -0,0 +1 @@
|
||||
uid://iko41fhoo3mm
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/agent_tools/headless/driver.gd" id="1"]
|
||||
|
||||
[node name="HeadlessDriver" type="Node"]
|
||||
script = ExtResource("1")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d2wklut1fj5ed"
|
||||
path="res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/agent_tools/icon.png"
|
||||
dest_files=["res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,9 @@
|
||||
[plugin]
|
||||
|
||||
name="Agent Tools"
|
||||
description="70 tools + 6 MCP Resources for coding agents to edit scenes, wire signals, create resources, and more through Godot's real APIs instead of hand-editing .tscn/.tres files. If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) per the README: https://github.com/BlakeBukowsky/GodotTools"
|
||||
author="Blake Bukowsky"
|
||||
version="0.3.1"
|
||||
license="MIT"
|
||||
script="plugin.gd"
|
||||
icon="icon.png"
|
||||
@@ -0,0 +1,132 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const Server := preload("res://addons/agent_tools/server.gd")
|
||||
const Registry := preload("res://addons/agent_tools/registry.gd")
|
||||
|
||||
const DEFAULT_PORT := 9920
|
||||
const PORT_RANGE_END := 9929 # scan 9920..9929 inclusive
|
||||
const PORT_SETTING := "agent_tools/port"
|
||||
const BRIDGE_AUTOLOAD_NAME := "_MCPGameBridge"
|
||||
const BRIDGE_AUTOLOAD_PATH := "res://addons/agent_tools/runtime/game_bridge.gd"
|
||||
|
||||
const DEFAULT_INTERFACE := "127.0.0.1"
|
||||
const INTERFACE_SETTING := "agent_tools/interface"
|
||||
|
||||
var _server: Server
|
||||
var _registry: Registry
|
||||
var _bound_port: int = -1
|
||||
var _session_file_path: String = ""
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
_registry = Registry.new()
|
||||
_server = Server.new(_registry)
|
||||
add_child(_server)
|
||||
|
||||
# Interface resolution
|
||||
# Defaults to 127.0.0.1, override with project.godot agent_tools/interface
|
||||
var forced_interface := DEFAULT_INTERFACE
|
||||
if ProjectSettings.has_setting(INTERFACE_SETTING):
|
||||
forced_interface = ProjectSettings.get_setting(INTERFACE_SETTING)
|
||||
|
||||
# Port resolution:
|
||||
# 1. If 'agent_tools/port' is set in project.godot, honor it strictly (fail if busy).
|
||||
# 2. Otherwise try DEFAULT_PORT..PORT_RANGE_END and use the first free one.
|
||||
# This lets multiple Godot editors coexist: first-to-start takes 9920, second takes 9921, etc.
|
||||
var forced_port := -1
|
||||
if ProjectSettings.has_setting(PORT_SETTING):
|
||||
forced_port = int(ProjectSettings.get_setting(PORT_SETTING))
|
||||
|
||||
if forced_port != -1:
|
||||
if _server.start(forced_port, forced_interface):
|
||||
_bound_port = forced_port
|
||||
else:
|
||||
push_error("[agent_tools] configured port %s:%d is busy. Either close the other process or change 'agent_tools/port' in project.godot." % [forced_interface, forced_port])
|
||||
else:
|
||||
for p in range(DEFAULT_PORT, PORT_RANGE_END + 1):
|
||||
if _server.start(p, forced_interface):
|
||||
_bound_port = p
|
||||
break
|
||||
if _bound_port == -1:
|
||||
push_error("[agent_tools] all ports %d-%d are busy on %s. Close other Godot editors running agent_tools, or set 'agent_tools/port' in project.godot." % [DEFAULT_PORT, PORT_RANGE_END, forced_interface])
|
||||
|
||||
if _bound_port != -1:
|
||||
print("[agent_tools] listening on %s:%d" % [forced_interface, _bound_port])
|
||||
print("[agent_tools] If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) to use these tools.")
|
||||
print("[agent_tools] Setup guide: https://github.com/BlakeBukowsky/GodotTools#configure-your-agent")
|
||||
_write_session_registry()
|
||||
|
||||
# Register the runtime bridge autoload so editor.game_screenshot / logs.read
|
||||
# can reach the running game. add_autoload_singleton() writes an entry to
|
||||
# project.godot; remove_autoload_singleton() in _exit_tree cleans it up.
|
||||
if not ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
|
||||
add_autoload_singleton(BRIDGE_AUTOLOAD_NAME, BRIDGE_AUTOLOAD_PATH)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_remove_session_registry()
|
||||
if _server:
|
||||
_server.stop()
|
||||
_server.queue_free()
|
||||
_server = null
|
||||
_registry = null
|
||||
_bound_port = -1
|
||||
if ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
|
||||
remove_autoload_singleton(BRIDGE_AUTOLOAD_NAME)
|
||||
|
||||
|
||||
# Session registry is a per-PID JSON file under a cross-project, user-scoped
|
||||
# directory. The MCP shim reads these on startup so `session.list` can discover
|
||||
# every running Godot editor + which port to talk to each.
|
||||
func _write_session_registry() -> void:
|
||||
var dir_path := _session_registry_dir()
|
||||
if dir_path == "":
|
||||
return
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
push_warning("[agent_tools] could not create session registry dir: %s" % dir_path)
|
||||
return
|
||||
var pid := OS.get_process_id()
|
||||
_session_file_path = dir_path.path_join("%d.json" % pid)
|
||||
|
||||
var project_name := ""
|
||||
if ProjectSettings.has_setting("application/config/name"):
|
||||
project_name = str(ProjectSettings.get_setting("application/config/name"))
|
||||
var version := Engine.get_version_info()
|
||||
|
||||
var entry := {
|
||||
"pid": pid,
|
||||
"port": _bound_port,
|
||||
"project_path": ProjectSettings.globalize_path("res://"),
|
||||
"project_name": project_name,
|
||||
"godot_version": "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status],
|
||||
"started_at_unix": int(Time.get_unix_time_from_system()),
|
||||
}
|
||||
var f := FileAccess.open(_session_file_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_warning("[agent_tools] could not write session file: %s" % _session_file_path)
|
||||
return
|
||||
f.store_string(JSON.stringify(entry, " "))
|
||||
f.close()
|
||||
|
||||
|
||||
func _remove_session_registry() -> void:
|
||||
if _session_file_path == "":
|
||||
return
|
||||
if FileAccess.file_exists(_session_file_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(_session_file_path)
|
||||
_session_file_path = ""
|
||||
|
||||
|
||||
# Cross-project location: <user home>/.godot-agent-tools/sessions/. Chosen to
|
||||
# sit outside any single project's res:// or user:// tree.
|
||||
func _session_registry_dir() -> String:
|
||||
var home := OS.get_environment("USERPROFILE")
|
||||
if home == "":
|
||||
home = OS.get_environment("HOME")
|
||||
if home == "":
|
||||
return ""
|
||||
return home.path_join(".godot-agent-tools").path_join("sessions")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkvb64lq51xyg
|
||||
@@ -0,0 +1,129 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const SceneTools := preload("res://addons/agent_tools/tools/scene_tools.gd")
|
||||
const SignalTools := preload("res://addons/agent_tools/tools/signal_tools.gd")
|
||||
const ScriptTools := preload("res://addons/agent_tools/tools/script_tools.gd")
|
||||
const ResourceTools := preload("res://addons/agent_tools/tools/resource_tools.gd")
|
||||
const RefsTools := preload("res://addons/agent_tools/tools/refs_tools.gd")
|
||||
const ProjectTools := preload("res://addons/agent_tools/tools/project_tools.gd")
|
||||
const EditorTools := preload("res://addons/agent_tools/tools/editor_tools.gd")
|
||||
const DocsTools := preload("res://addons/agent_tools/tools/docs_tools.gd")
|
||||
const InputTools := preload("res://addons/agent_tools/tools/input_tools.gd")
|
||||
const RunTools := preload("res://addons/agent_tools/tools/run_tools.gd")
|
||||
const FsTools := preload("res://addons/agent_tools/tools/fs_tools.gd")
|
||||
const AnimationTools := preload("res://addons/agent_tools/tools/animation_tools.gd")
|
||||
const UserFsTools := preload("res://addons/agent_tools/tools/user_fs_tools.gd")
|
||||
const PerformanceTools := preload("res://addons/agent_tools/tools/performance_tools.gd")
|
||||
const TestTools := preload("res://addons/agent_tools/tools/test_tools.gd")
|
||||
const ClientTools := preload("res://addons/agent_tools/tools/client_tools.gd")
|
||||
const PhysicsTools := preload("res://addons/agent_tools/tools/physics_tools.gd")
|
||||
const ThemeTools := preload("res://addons/agent_tools/tools/theme_tools.gd")
|
||||
|
||||
|
||||
# Returns {"data": <any>} on success, {"error": {"code": int, "message": str}} on failure.
|
||||
func dispatch(method: String, params: Dictionary) -> Dictionary:
|
||||
match method:
|
||||
"scene.new": return SceneTools.new_scene(params)
|
||||
"scene.add_node": return SceneTools.add_node(params)
|
||||
"scene.instance_packed": return SceneTools.instance_packed(params)
|
||||
"scene.duplicate_node": return SceneTools.duplicate_node(params)
|
||||
"scene.remove_node": return SceneTools.remove_node(params)
|
||||
"scene.reparent": return SceneTools.reparent(params)
|
||||
"scene.set_property": return SceneTools.set_property(params)
|
||||
"scene.get_property": return SceneTools.get_property(params)
|
||||
"scene.call_method": return SceneTools.call_method(params)
|
||||
"scene.build_tree": return SceneTools.build_tree(params)
|
||||
"scene.open": return SceneTools.open_scene(params)
|
||||
"scene.save": return SceneTools.save_scene(params)
|
||||
"scene.current": return SceneTools.current(params)
|
||||
"scene.inspect": return SceneTools.inspect(params)
|
||||
"scene.capture_screenshot": return SceneTools.capture_screenshot(params)
|
||||
"signal.connect": return SignalTools.connect_signal(params)
|
||||
"signal.disconnect": return SignalTools.disconnect_signal(params)
|
||||
"signal.list": return SignalTools.list_signals(params)
|
||||
"script.create": return ScriptTools.create(params)
|
||||
"script.attach": return ScriptTools.attach(params)
|
||||
"script.patch": return ScriptTools.patch(params)
|
||||
"resource.create": return ResourceTools.create(params)
|
||||
"resource.set_property": return ResourceTools.set_property(params)
|
||||
"resource.call_method": return ResourceTools.call_method(params)
|
||||
"refs.validate_project": return RefsTools.validate_project(params)
|
||||
"refs.find_usages": return RefsTools.find_usages(params)
|
||||
"refs.rename": return RefsTools.rename(params)
|
||||
"refs.rename_class": return RefsTools.rename_class(params)
|
||||
"project.get_setting": return ProjectTools.get_setting(params)
|
||||
"project.set_setting": return ProjectTools.set_setting(params)
|
||||
"autoload.add": return ProjectTools.autoload_add(params)
|
||||
"autoload.remove": return ProjectTools.autoload_remove(params)
|
||||
"autoload.list": return ProjectTools.autoload_list(params)
|
||||
"editor.reload_filesystem": return EditorTools.reload_filesystem(params)
|
||||
"editor.save_all_scenes": return EditorTools.save_all_scenes(params)
|
||||
"editor.state": return EditorTools.state(params)
|
||||
"editor.selection_get": return EditorTools.selection_get(params)
|
||||
"editor.selection_set": return EditorTools.selection_set(params)
|
||||
"editor.game_screenshot": return EditorTools.game_screenshot(params)
|
||||
"logs.read": return EditorTools.logs_read(params)
|
||||
"logs.clear": return EditorTools.logs_clear(params)
|
||||
"docs.class_ref": return DocsTools.class_ref(params)
|
||||
"input_map.add_action": return InputTools.add_action(params)
|
||||
"input_map.add_event": return InputTools.add_event(params)
|
||||
"input_map.list": return InputTools.list_actions(params)
|
||||
"input_map.remove_action": return InputTools.remove_action(params)
|
||||
"input_map.remove_event": return InputTools.remove_event(params)
|
||||
"run.scene_headless": return RunTools.scene_headless(params)
|
||||
"fs.list": return FsTools.list(params)
|
||||
"fs.read_text": return FsTools.read_text(params)
|
||||
"fs.write_text": return FsTools.write_text(params)
|
||||
"user_fs.read": return UserFsTools.read(params)
|
||||
"user_fs.list": return UserFsTools.list(params)
|
||||
"animation.list": return AnimationTools.list_animations(params)
|
||||
"animation.add_animation": return AnimationTools.add_animation(params)
|
||||
"animation.remove_animation": return AnimationTools.remove_animation(params)
|
||||
"animation.add_value_track": return AnimationTools.add_value_track(params)
|
||||
"performance.monitors": return PerformanceTools.monitors(params)
|
||||
"test.run": return TestTools.run(params)
|
||||
"client.list": return ClientTools.list_clients(params)
|
||||
"client.configure": return ClientTools.configure(params)
|
||||
"client.remove": return ClientTools.remove(params)
|
||||
"physics.autofit_collision_shape_2d": return PhysicsTools.autofit_collision_shape_2d(params)
|
||||
"theme.set_color": return ThemeTools.set_color(params)
|
||||
"theme.set_constant": return ThemeTools.set_constant(params)
|
||||
"theme.set_font_size": return ThemeTools.set_font_size(params)
|
||||
"theme.set_stylebox_flat": return ThemeTools.set_stylebox_flat(params)
|
||||
"batch.execute": return batch_execute(params)
|
||||
_:
|
||||
return {"error": {"code": -32601, "message": "method not found: %s" % method}}
|
||||
|
||||
|
||||
# batch.execute — run multiple tool calls in one round trip.
|
||||
# Params: {calls: [{method, params?}, ...], stop_on_error?: false}
|
||||
# Returns: {results: [{method, ok, data|error, index}, ...]}
|
||||
func batch_execute(params: Dictionary) -> Dictionary:
|
||||
var calls: Array = params.get("calls", [])
|
||||
var stop_on_error: bool = params.get("stop_on_error", false)
|
||||
if calls.is_empty():
|
||||
return {"error": {"code": -32602, "message": "missing 'calls' (array of {method, params})"}}
|
||||
|
||||
var results: Array = []
|
||||
for i in calls.size():
|
||||
var entry = calls[i]
|
||||
if typeof(entry) != TYPE_DICTIONARY or not entry.has("method"):
|
||||
results.append({"index": i, "ok": false, "error": "each call needs {method, params?}"})
|
||||
if stop_on_error:
|
||||
break
|
||||
continue
|
||||
var sub_method: String = entry.method
|
||||
var sub_params: Dictionary = entry.get("params", {})
|
||||
var sub_result: Dictionary = dispatch(sub_method, sub_params)
|
||||
var row := {"index": i, "method": sub_method}
|
||||
if sub_result.has("error"):
|
||||
row["ok"] = false
|
||||
row["error"] = sub_result.error
|
||||
else:
|
||||
row["ok"] = true
|
||||
row["data"] = sub_result.get("data")
|
||||
results.append(row)
|
||||
if stop_on_error and not row.ok:
|
||||
break
|
||||
return {"data": {"results": results, "count": results.size(), "requested": calls.size()}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5pe4omwjkjgo
|
||||
@@ -0,0 +1,31 @@
|
||||
# Custom Logger that forwards every captured message to the game bridge's
|
||||
# log buffer. Has to live in its own top-level file — nested classes don't
|
||||
# reliably register Logger's virtual overrides (_log_error / _log_message).
|
||||
|
||||
extends Logger
|
||||
|
||||
var _bridge: Node
|
||||
|
||||
|
||||
func _init(bridge: Node) -> void:
|
||||
_bridge = bridge
|
||||
|
||||
|
||||
func _log_error(function: String, file: String, line: int, code: String, rationale: String, editor_notify: bool, error_type: int, script_backtraces: Array) -> void:
|
||||
# error_type is Logger.ErrorType: 0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER.
|
||||
var level: String
|
||||
match error_type:
|
||||
1: level = "WARNING"
|
||||
2: level = "SCRIPT"
|
||||
3: level = "SHADER"
|
||||
_: level = "ERROR"
|
||||
var msg: String = rationale if rationale != "" else code
|
||||
if file != "" and line > 0:
|
||||
msg += " (at %s:%d in %s)" % [file, line, function]
|
||||
if _bridge and _bridge.has_method("log_entry"):
|
||||
_bridge.log_entry(level, msg)
|
||||
|
||||
|
||||
func _log_message(message: String, error: bool) -> void:
|
||||
if _bridge and _bridge.has_method("log_entry"):
|
||||
_bridge.log_entry("ERROR" if error else "INFO", message.strip_edges())
|
||||
@@ -0,0 +1 @@
|
||||
uid://wosqs8dlh1vx
|
||||
@@ -0,0 +1,134 @@
|
||||
# MCPGameBridge — runtime autoload registered by the Agent Tools plugin.
|
||||
#
|
||||
# Bridges the editor-side agent tools to the *running* game via a file-based
|
||||
# request/response channel under res://.godot/agent_tools/bridge/. Currently:
|
||||
# - screenshot: capture the running viewport, save to a path
|
||||
# - logs: append print/push_error/push_warning output to a buffer
|
||||
#
|
||||
# This script ONLY runs in the game process (checks Engine.is_editor_hint()
|
||||
# on startup and disables itself in the editor). The editor plugin registers
|
||||
# it via add_autoload_singleton() on _enter_tree and removes it on _exit_tree.
|
||||
|
||||
extends Node
|
||||
|
||||
const BridgeLogger := preload("res://addons/agent_tools/runtime/bridge_logger.gd")
|
||||
const BRIDGE_DIR := "res://.godot/agent_tools/bridge"
|
||||
const REQUEST_PATH := BRIDGE_DIR + "/request.json"
|
||||
const RESPONSE_PATH := BRIDGE_DIR + "/response.json"
|
||||
const LOGS_PATH := BRIDGE_DIR + "/logs.json"
|
||||
const POLL_INTERVAL_SEC := 0.05 # 20 Hz — cheap enough, responsive enough
|
||||
const LOG_BUFFER_MAX := 500
|
||||
|
||||
var _timer: Timer
|
||||
var _log_buffer: Array = []
|
||||
var _initialized := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
# Tool mode — don't do anything inside the editor. The bridge only
|
||||
# makes sense in the running game.
|
||||
return
|
||||
_initialized = true
|
||||
_ensure_bridge_dir()
|
||||
# Poll for editor requests on a timer so we don't eat a _process callback.
|
||||
_timer = Timer.new()
|
||||
_timer.wait_time = POLL_INTERVAL_SEC
|
||||
_timer.one_shot = false
|
||||
_timer.autostart = true
|
||||
_timer.timeout.connect(_poll_request)
|
||||
add_child(_timer)
|
||||
# Attach our custom logger to capture print / push_error / push_warning
|
||||
# from the game and mirror them to the log buffer file so logs.read can
|
||||
# pick them up from the editor side.
|
||||
var logger := BridgeLogger.new(self)
|
||||
OS.add_logger(logger)
|
||||
|
||||
|
||||
func _ensure_bridge_dir() -> void:
|
||||
if not DirAccess.dir_exists_absolute(BRIDGE_DIR):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.make_dir_recursive_absolute(BRIDGE_DIR)
|
||||
|
||||
|
||||
# Called by MCPBridgeLogger whenever the game writes to stdout/stderr.
|
||||
func log_entry(level: String, message: String) -> void:
|
||||
_log_buffer.append({
|
||||
"level": level,
|
||||
"message": message,
|
||||
"time_ms": Time.get_ticks_msec(),
|
||||
})
|
||||
if _log_buffer.size() > LOG_BUFFER_MAX:
|
||||
_log_buffer = _log_buffer.slice(_log_buffer.size() - LOG_BUFFER_MAX)
|
||||
# Flush to disk so editor-side logs.read sees it. Debounced by poll interval
|
||||
# in practice because each log call flushes but writes are cheap.
|
||||
_flush_logs()
|
||||
|
||||
|
||||
func _flush_logs() -> void:
|
||||
var f := FileAccess.open(LOGS_PATH, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return
|
||||
f.store_string(JSON.stringify(_log_buffer))
|
||||
f.close()
|
||||
|
||||
|
||||
func _poll_request() -> void:
|
||||
if not FileAccess.file_exists(REQUEST_PATH):
|
||||
return
|
||||
var f := FileAccess.open(REQUEST_PATH, FileAccess.READ)
|
||||
if f == null:
|
||||
return
|
||||
var body := f.get_as_text()
|
||||
f.close()
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(REQUEST_PATH)
|
||||
|
||||
var parsed = JSON.parse_string(body)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_write_response({"id": "", "error": "malformed request"})
|
||||
return
|
||||
var req_id: String = parsed.get("id", "")
|
||||
match parsed.get("type", ""):
|
||||
"screenshot":
|
||||
_handle_screenshot(req_id, parsed)
|
||||
_:
|
||||
_write_response({"id": req_id, "error": "unknown request type: %s" % parsed.get("type", "<missing>")})
|
||||
|
||||
|
||||
func _handle_screenshot(req_id: String, req: Dictionary) -> void:
|
||||
var output_path: String = req.get("output", "")
|
||||
if output_path == "":
|
||||
_write_response({"id": req_id, "error": "missing 'output'"})
|
||||
return
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
_write_response({"id": req_id, "error": "no viewport available"})
|
||||
return
|
||||
var tex := vp.get_texture()
|
||||
if tex == null:
|
||||
_write_response({"id": req_id, "error": "viewport has no texture"})
|
||||
return
|
||||
var img := tex.get_image()
|
||||
if img == null or img.is_empty():
|
||||
_write_response({"id": req_id, "error": "failed to capture image"})
|
||||
return
|
||||
var dir := output_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var save_err := img.save_png(output_path)
|
||||
if save_err != OK:
|
||||
_write_response({"id": req_id, "error": "save_png failed: %d" % save_err})
|
||||
return
|
||||
_write_response({"id": req_id, "path": output_path})
|
||||
|
||||
|
||||
func _write_response(payload: Dictionary) -> void:
|
||||
var f := FileAccess.open(RESPONSE_PATH, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return
|
||||
f.store_string(JSON.stringify(payload))
|
||||
f.close()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cds186n8uv4f0
|
||||
@@ -0,0 +1,125 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
# Line-delimited JSON-RPC over TCP on localhost.
|
||||
# One request per line: {"id": <any>, "method": "<name>", "params": {...}}
|
||||
# One response per line: {"id": <echoed>, "result": <data>} OR {"id": ..., "error": {"code": int, "message": str, "data"?: any}}
|
||||
|
||||
const MAX_MESSAGE_BYTES := 1_048_576 # 1 MiB per line — safety cap
|
||||
|
||||
var _tcp: TCPServer
|
||||
var _clients: Array = [] # Array of {peer: StreamPeerTCP, buf: PackedByteArray}
|
||||
var _registry
|
||||
|
||||
|
||||
func _init(registry) -> void:
|
||||
_registry = registry
|
||||
|
||||
|
||||
func start(port: int, interface: String = "127.0.0.1") -> bool:
|
||||
_tcp = TCPServer.new()
|
||||
var err := _tcp.listen(port, interface)
|
||||
if err != OK:
|
||||
_tcp = null
|
||||
return false
|
||||
set_process(true)
|
||||
return true
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
set_process(false)
|
||||
for c in _clients:
|
||||
(c.peer as StreamPeerTCP).disconnect_from_host()
|
||||
_clients.clear()
|
||||
if _tcp:
|
||||
_tcp.stop()
|
||||
_tcp = null
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _tcp == null:
|
||||
return
|
||||
|
||||
while _tcp.is_connection_available():
|
||||
var peer := _tcp.take_connection()
|
||||
_clients.append({"peer": peer, "buf": PackedByteArray()})
|
||||
|
||||
var still_alive: Array = []
|
||||
for c in _clients:
|
||||
var peer: StreamPeerTCP = c.peer
|
||||
peer.poll()
|
||||
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
continue
|
||||
var available := peer.get_available_bytes()
|
||||
if available > 0:
|
||||
var chunk := peer.get_data(available)
|
||||
if chunk[0] == OK:
|
||||
c.buf.append_array(chunk[1])
|
||||
_drain_messages(c)
|
||||
if (c.buf as PackedByteArray).size() > MAX_MESSAGE_BYTES:
|
||||
_send_error(peer, null, -32700, "message exceeds %d bytes" % MAX_MESSAGE_BYTES)
|
||||
peer.disconnect_from_host()
|
||||
continue
|
||||
still_alive.append(c)
|
||||
_clients = still_alive
|
||||
|
||||
|
||||
func _drain_messages(c: Dictionary) -> void:
|
||||
while true:
|
||||
var buf: PackedByteArray = c.buf
|
||||
var nl := buf.find(0x0A) # \n
|
||||
if nl < 0:
|
||||
return
|
||||
var line_bytes := buf.slice(0, nl)
|
||||
c.buf = buf.slice(nl + 1)
|
||||
var line := line_bytes.get_string_from_utf8().strip_edges()
|
||||
if line == "":
|
||||
continue
|
||||
_handle_line(c.peer, line)
|
||||
|
||||
|
||||
func _handle_line(peer: StreamPeerTCP, line: String) -> void:
|
||||
var parsed = JSON.parse_string(line)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_send_error(peer, null, -32700, "parse error — expected JSON object")
|
||||
return
|
||||
var id = parsed.get("id")
|
||||
var method: String = parsed.get("method", "")
|
||||
var params: Dictionary = parsed.get("params", {})
|
||||
if method == "":
|
||||
_send_error(peer, id, -32600, "missing 'method'")
|
||||
return
|
||||
|
||||
var result: Dictionary = _registry.dispatch(method, params)
|
||||
# Guard against silent tool-module failures: when a tool's .gd file has a parse
|
||||
# error, preload() returns null, calls on it produce no output, and dispatch
|
||||
# returns its default {}. Surface that instead of sending {"result": null}.
|
||||
if result.is_empty():
|
||||
_send_error(peer, id, -32000,
|
||||
"tool returned empty response — likely a parse error in the tool module. Check Godot's Output panel for the actual error.",
|
||||
{"method": method})
|
||||
elif result.has("error"):
|
||||
var e: Dictionary = result.error
|
||||
_send_error(peer, id, e.get("code", -32000), e.get("message", ""), e.get("data"))
|
||||
elif result.has("data"):
|
||||
_send_result(peer, id, result.data)
|
||||
else:
|
||||
_send_error(peer, id, -32000,
|
||||
"tool returned malformed response (missing both 'data' and 'error')",
|
||||
{"method": method})
|
||||
|
||||
|
||||
func _send_result(peer: StreamPeerTCP, id, data) -> void:
|
||||
_write(peer, {"id": id, "result": data})
|
||||
|
||||
|
||||
func _send_error(peer: StreamPeerTCP, id, code: int, msg: String, data = null) -> void:
|
||||
var err := {"code": code, "message": msg}
|
||||
if data != null:
|
||||
err["data"] = data
|
||||
_write(peer, {"id": id, "error": err})
|
||||
|
||||
|
||||
func _write(peer: StreamPeerTCP, obj: Dictionary) -> void:
|
||||
var line := JSON.stringify(obj) + "\n"
|
||||
peer.put_data(line.to_utf8_buffer())
|
||||
@@ -0,0 +1 @@
|
||||
uid://pcfsf0ijlm8x
|
||||
@@ -0,0 +1,306 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Shared JSON-to-Godot type coercion for scene.set_property, resource.set_property,
|
||||
# and any other tool that assigns a user-provided value into a typed property slot.
|
||||
#
|
||||
# Contract:
|
||||
# coerce(value, target_type: int) -> Variant
|
||||
#
|
||||
# Returns the coerced value on success. On mismatch, returns a Dictionary with
|
||||
# a single "_error" key describing the failure — callers check `is Dictionary`
|
||||
# and forward the message as a tool-level error.
|
||||
#
|
||||
# New types go here. Don't re-add `_coerce` to individual tool files; route
|
||||
# through Coerce.coerce() instead.
|
||||
|
||||
static func coerce(value, target_type: int):
|
||||
match target_type:
|
||||
TYPE_BOOL:
|
||||
# Godot 4 has no `bool()` constructor (it errors at runtime). Convert via
|
||||
# truthiness, but parse strings explicitly so "false"/"0" don't read as
|
||||
# truthy the way any non-empty string otherwise would.
|
||||
if value is String:
|
||||
return value.strip_edges().to_lower() in ["true", "1", "yes", "on"]
|
||||
return true if value else false
|
||||
TYPE_INT:
|
||||
return int(value)
|
||||
TYPE_FLOAT:
|
||||
return float(value)
|
||||
TYPE_STRING, TYPE_STRING_NAME:
|
||||
return String(value)
|
||||
TYPE_NODE_PATH:
|
||||
return NodePath(String(value))
|
||||
TYPE_VECTOR2:
|
||||
if value is Array and value.size() == 2:
|
||||
return Vector2(value[0], value[1])
|
||||
return {"_error": "Vector2 expects [x, y]"}
|
||||
TYPE_VECTOR2I:
|
||||
if value is Array and value.size() == 2:
|
||||
return Vector2i(int(value[0]), int(value[1]))
|
||||
return {"_error": "Vector2i expects [x, y]"}
|
||||
TYPE_VECTOR3:
|
||||
if value is Array and value.size() == 3:
|
||||
return Vector3(value[0], value[1], value[2])
|
||||
return {"_error": "Vector3 expects [x, y, z]"}
|
||||
TYPE_VECTOR3I:
|
||||
if value is Array and value.size() == 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
return {"_error": "Vector3i expects [x, y, z]"}
|
||||
TYPE_VECTOR4:
|
||||
if value is Array and value.size() == 4:
|
||||
return Vector4(value[0], value[1], value[2], value[3])
|
||||
return {"_error": "Vector4 expects [x, y, z, w]"}
|
||||
TYPE_VECTOR4I:
|
||||
if value is Array and value.size() == 4:
|
||||
return Vector4i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
|
||||
return {"_error": "Vector4i expects [x, y, z, w]"}
|
||||
TYPE_RECT2:
|
||||
if value is Array and value.size() == 4:
|
||||
return Rect2(value[0], value[1], value[2], value[3])
|
||||
return {"_error": "Rect2 expects [x, y, width, height]"}
|
||||
TYPE_RECT2I:
|
||||
if value is Array and value.size() == 4:
|
||||
return Rect2i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
|
||||
return {"_error": "Rect2i expects [x, y, width, height]"}
|
||||
TYPE_QUATERNION:
|
||||
if value is Array and value.size() == 4:
|
||||
return Quaternion(value[0], value[1], value[2], value[3])
|
||||
return {"_error": "Quaternion expects [x, y, z, w]"}
|
||||
TYPE_TRANSFORM2D:
|
||||
# TRS form: {origin: [x,y], rotation: radians, scale: [x,y]} — all optional.
|
||||
if value is Dictionary:
|
||||
var origin = value.get("origin", [0, 0])
|
||||
var rotation: float = float(value.get("rotation", 0.0))
|
||||
var scale_v = value.get("scale", [1, 1])
|
||||
var skew: float = float(value.get("skew", 0.0))
|
||||
if not (origin is Array and origin.size() == 2 and scale_v is Array and scale_v.size() == 2):
|
||||
return {"_error": "Transform2D origin/scale expect [x, y]"}
|
||||
return Transform2D(
|
||||
rotation,
|
||||
Vector2(scale_v[0], scale_v[1]),
|
||||
skew,
|
||||
Vector2(origin[0], origin[1])
|
||||
)
|
||||
return {"_error": "Transform2D expects {origin: [x,y], rotation: radians, scale: [x,y], skew?}"}
|
||||
TYPE_TRANSFORM3D:
|
||||
# TRS form: {origin: [x,y,z], rotation: [x,y,z] euler radians, scale: [x,y,z]}.
|
||||
if value is Dictionary:
|
||||
var origin = value.get("origin", [0, 0, 0])
|
||||
var rotation = value.get("rotation", [0, 0, 0])
|
||||
var scale_v = value.get("scale", [1, 1, 1])
|
||||
if not (origin is Array and origin.size() == 3
|
||||
and rotation is Array and rotation.size() == 3
|
||||
and scale_v is Array and scale_v.size() == 3):
|
||||
return {"_error": "Transform3D origin/rotation/scale expect [x, y, z]"}
|
||||
var basis := Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2]))
|
||||
basis = basis.scaled(Vector3(scale_v[0], scale_v[1], scale_v[2]))
|
||||
return Transform3D(basis, Vector3(origin[0], origin[1], origin[2]))
|
||||
return {"_error": "Transform3D expects {origin: [x,y,z], rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
|
||||
TYPE_BASIS:
|
||||
if value is Dictionary:
|
||||
var rotation = value.get("rotation", [0, 0, 0])
|
||||
var scale_v = value.get("scale", [1, 1, 1])
|
||||
if not (rotation is Array and rotation.size() == 3
|
||||
and scale_v is Array and scale_v.size() == 3):
|
||||
return {"_error": "Basis rotation/scale expect [x, y, z]"}
|
||||
return Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2])).scaled(
|
||||
Vector3(scale_v[0], scale_v[1], scale_v[2]))
|
||||
return {"_error": "Basis expects {rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
|
||||
TYPE_AABB:
|
||||
if value is Dictionary:
|
||||
var pos = value.get("position", [0, 0, 0])
|
||||
var size = value.get("size", [0, 0, 0])
|
||||
if not (pos is Array and pos.size() == 3 and size is Array and size.size() == 3):
|
||||
return {"_error": "AABB position/size expect [x, y, z]"}
|
||||
return AABB(Vector3(pos[0], pos[1], pos[2]), Vector3(size[0], size[1], size[2]))
|
||||
return {"_error": "AABB expects {position: [x,y,z], size: [x,y,z]}"}
|
||||
TYPE_PLANE:
|
||||
if value is Dictionary:
|
||||
var normal = value.get("normal", [0, 1, 0])
|
||||
var d: float = float(value.get("d", 0.0))
|
||||
if not (normal is Array and normal.size() == 3):
|
||||
return {"_error": "Plane normal expects [x, y, z]"}
|
||||
return Plane(Vector3(normal[0], normal[1], normal[2]), d)
|
||||
return {"_error": "Plane expects {normal: [x,y,z], d: float}"}
|
||||
TYPE_COLOR:
|
||||
if value is Array and (value.size() == 3 or value.size() == 4):
|
||||
var a := float(value[3]) if value.size() == 4 else 1.0
|
||||
return Color(value[0], value[1], value[2], a)
|
||||
if value is String:
|
||||
return Color(value)
|
||||
return {"_error": "Color expects [r,g,b(,a)] or '#rrggbb(aa)'"}
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedStringArray()
|
||||
for s in value:
|
||||
out.append(String(s))
|
||||
return out
|
||||
return {"_error": "PackedStringArray expects an array of strings"}
|
||||
TYPE_PACKED_INT32_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedInt32Array()
|
||||
for i in value:
|
||||
out.append(int(i))
|
||||
return out
|
||||
return {"_error": "PackedInt32Array expects an array of numbers"}
|
||||
TYPE_PACKED_INT64_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedInt64Array()
|
||||
for i in value:
|
||||
out.append(int(i))
|
||||
return out
|
||||
return {"_error": "PackedInt64Array expects an array of numbers"}
|
||||
TYPE_PACKED_FLOAT32_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedFloat32Array()
|
||||
for f in value:
|
||||
out.append(float(f))
|
||||
return out
|
||||
return {"_error": "PackedFloat32Array expects an array of numbers"}
|
||||
TYPE_PACKED_FLOAT64_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedFloat64Array()
|
||||
for f in value:
|
||||
out.append(float(f))
|
||||
return out
|
||||
return {"_error": "PackedFloat64Array expects an array of numbers"}
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector2Array()
|
||||
for v in value:
|
||||
if v is Array and v.size() == 2:
|
||||
out.append(Vector2(v[0], v[1]))
|
||||
else:
|
||||
return {"_error": "PackedVector2Array expects [[x, y], ...]"}
|
||||
return out
|
||||
return {"_error": "PackedVector2Array expects an array of [x, y] pairs"}
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector3Array()
|
||||
for v in value:
|
||||
if v is Array and v.size() == 3:
|
||||
out.append(Vector3(v[0], v[1], v[2]))
|
||||
else:
|
||||
return {"_error": "PackedVector3Array expects [[x, y, z], ...]"}
|
||||
return out
|
||||
return {"_error": "PackedVector3Array expects an array of [x, y, z] triples"}
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedColorArray()
|
||||
for c in value:
|
||||
if c is Array and (c.size() == 3 or c.size() == 4):
|
||||
var a := float(c[3]) if c.size() == 4 else 1.0
|
||||
out.append(Color(c[0], c[1], c[2], a))
|
||||
elif c is String:
|
||||
out.append(Color(c))
|
||||
else:
|
||||
return {"_error": "PackedColorArray expects [[r,g,b(,a)], ...] or ['#hex', ...]"}
|
||||
return out
|
||||
return {"_error": "PackedColorArray expects an array of colors"}
|
||||
TYPE_OBJECT:
|
||||
# Auto-load a Resource when the caller passes a "res://..." or "uid://..." path string.
|
||||
# Without this, Godot silently drops the assignment and leaves the slot null.
|
||||
if value is String and (value.begins_with("res://") or value.begins_with("uid://")):
|
||||
var loaded := ResourceLoader.load(value)
|
||||
if loaded == null:
|
||||
return {"_error": "failed to load resource: %s" % value}
|
||||
return loaded
|
||||
return value
|
||||
_:
|
||||
return value
|
||||
|
||||
|
||||
# Inverse of coerce(): convert a Godot value back to a JSON-native form so the
|
||||
# response echo is unambiguous data rather than Godot's str() stringification
|
||||
# (which turns Vector2 into "(x, y)", Color into "(r, g, b, a)", null Object
|
||||
# into "<Object#null>", etc., all of which look like success strings).
|
||||
static func to_json(value):
|
||||
match typeof(value):
|
||||
TYPE_VECTOR2:
|
||||
return [value.x, value.y]
|
||||
TYPE_VECTOR2I:
|
||||
return [int(value.x), int(value.y)]
|
||||
TYPE_VECTOR3:
|
||||
return [value.x, value.y, value.z]
|
||||
TYPE_VECTOR3I:
|
||||
return [int(value.x), int(value.y), int(value.z)]
|
||||
TYPE_VECTOR4:
|
||||
return [value.x, value.y, value.z, value.w]
|
||||
TYPE_VECTOR4I:
|
||||
return [int(value.x), int(value.y), int(value.z), int(value.w)]
|
||||
TYPE_RECT2:
|
||||
return [value.position.x, value.position.y, value.size.x, value.size.y]
|
||||
TYPE_RECT2I:
|
||||
return [int(value.position.x), int(value.position.y), int(value.size.x), int(value.size.y)]
|
||||
TYPE_COLOR:
|
||||
return [value.r, value.g, value.b, value.a]
|
||||
TYPE_QUATERNION:
|
||||
return [value.x, value.y, value.z, value.w]
|
||||
TYPE_NODE_PATH:
|
||||
return String(value)
|
||||
TYPE_STRING_NAME:
|
||||
return String(value)
|
||||
TYPE_TRANSFORM2D:
|
||||
return {
|
||||
"origin": [value.origin.x, value.origin.y],
|
||||
"x": [value.x.x, value.x.y],
|
||||
"y": [value.y.x, value.y.y],
|
||||
}
|
||||
TYPE_TRANSFORM3D:
|
||||
return {
|
||||
"origin": [value.origin.x, value.origin.y, value.origin.z],
|
||||
"basis": to_json(value.basis),
|
||||
}
|
||||
TYPE_BASIS:
|
||||
return [
|
||||
[value.x.x, value.x.y, value.x.z],
|
||||
[value.y.x, value.y.y, value.y.z],
|
||||
[value.z.x, value.z.y, value.z.z],
|
||||
]
|
||||
TYPE_AABB:
|
||||
return {
|
||||
"position": [value.position.x, value.position.y, value.position.z],
|
||||
"size": [value.size.x, value.size.y, value.size.z],
|
||||
}
|
||||
TYPE_PLANE:
|
||||
return {
|
||||
"normal": [value.normal.x, value.normal.y, value.normal.z],
|
||||
"d": value.d,
|
||||
}
|
||||
TYPE_OBJECT:
|
||||
if value == null:
|
||||
return null
|
||||
if value is Resource and value.resource_path != "":
|
||||
return {"class": value.get_class(), "resource_path": value.resource_path}
|
||||
return {"class": (value as Object).get_class()}
|
||||
TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, \
|
||||
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY:
|
||||
return Array(value)
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
var out_v2: Array = []
|
||||
for v in value:
|
||||
out_v2.append([v.x, v.y])
|
||||
return out_v2
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
var out_v3: Array = []
|
||||
for v in value:
|
||||
out_v3.append([v.x, v.y, v.z])
|
||||
return out_v3
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
var out_c: Array = []
|
||||
for c in value:
|
||||
out_c.append([c.r, c.g, c.b, c.a])
|
||||
return out_c
|
||||
TYPE_ARRAY:
|
||||
var out_a: Array = []
|
||||
for item in value:
|
||||
out_a.append(to_json(item))
|
||||
return out_a
|
||||
TYPE_DICTIONARY:
|
||||
var out_d: Dictionary = {}
|
||||
for k in value:
|
||||
out_d[str(k)] = to_json(value[k])
|
||||
return out_d
|
||||
_:
|
||||
return value
|
||||
@@ -0,0 +1 @@
|
||||
uid://cncjf8g7jufrc
|
||||
@@ -0,0 +1,210 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Tools for working with AnimationPlayer nodes in the currently-edited scene.
|
||||
# Animations live in AnimationLibrary resources attached to the player; the "" library
|
||||
# is the default, named libraries prefix animations as "lib/anim".
|
||||
|
||||
# animation.list — list animations on an AnimationPlayer with their tracks.
|
||||
# Params: {node_path}
|
||||
static func list_animations(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path == "":
|
||||
return _err(-32602, "missing 'node_path'")
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
var out: Array = []
|
||||
for lib_name in ap.get_animation_library_list():
|
||||
var lib: AnimationLibrary = ap.get_animation_library(lib_name)
|
||||
for anim_name in lib.get_animation_list():
|
||||
var anim: Animation = lib.get_animation(anim_name)
|
||||
var full_name: String
|
||||
if String(lib_name) == "":
|
||||
full_name = String(anim_name)
|
||||
else:
|
||||
full_name = "%s/%s" % [lib_name, anim_name]
|
||||
var tracks: Array = []
|
||||
for i in anim.get_track_count():
|
||||
tracks.append({
|
||||
"index": i,
|
||||
"type": _track_type_name(anim.track_get_type(i)),
|
||||
"path": String(anim.track_get_path(i)),
|
||||
"key_count": anim.track_get_key_count(i),
|
||||
})
|
||||
out.append({
|
||||
"name": full_name,
|
||||
"length": anim.length,
|
||||
"loop_mode": anim.loop_mode,
|
||||
"step": anim.step,
|
||||
"tracks": tracks,
|
||||
})
|
||||
return _ok({"node_path": node_path, "animations": out})
|
||||
|
||||
|
||||
# animation.add_animation — create an empty animation in the player's library.
|
||||
# Params: {node_path, name, length?: 1.0, library?: ""}
|
||||
static func add_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("name", "")
|
||||
var length: float = float(params.get("length", 1.0))
|
||||
var library_name: String = params.get("library", "")
|
||||
if node_path == "" or anim_name == "":
|
||||
return _err(-32602, "missing 'node_path' or 'name'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
var lib: AnimationLibrary
|
||||
if ap.has_animation_library(library_name):
|
||||
lib = ap.get_animation_library(library_name)
|
||||
else:
|
||||
lib = AnimationLibrary.new()
|
||||
var add_err: int = ap.add_animation_library(library_name, lib)
|
||||
if add_err != OK:
|
||||
return _err(-32001, "add_animation_library failed: %d" % add_err)
|
||||
|
||||
if lib.has_animation(anim_name):
|
||||
return _err(-32602, "animation already exists: %s" % anim_name)
|
||||
|
||||
var anim := Animation.new()
|
||||
anim.length = length
|
||||
@warning_ignore("return_value_discarded")
|
||||
lib.add_animation(anim_name, anim)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"name": anim_name, "library": library_name, "length": length})
|
||||
|
||||
|
||||
# animation.remove_animation — delete an animation from the player.
|
||||
# Params: {node_path, name, library?: ""}
|
||||
static func remove_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("name", "")
|
||||
var library_name: String = params.get("library", "")
|
||||
if node_path == "" or anim_name == "":
|
||||
return _err(-32602, "missing 'node_path' or 'name'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer")
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
if not ap.has_animation_library(library_name):
|
||||
return _err(-32001, "library not found: '%s'" % library_name)
|
||||
var lib: AnimationLibrary = ap.get_animation_library(library_name)
|
||||
if not lib.has_animation(anim_name):
|
||||
return _err(-32001, "animation not found: %s" % anim_name)
|
||||
lib.remove_animation(anim_name)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"removed": anim_name, "library": library_name})
|
||||
|
||||
|
||||
# animation.add_value_track — add a value track that animates a property over time.
|
||||
# target_node is resolved relative to the scene root (simpler than the
|
||||
# AnimationPlayer.root_node-relative resolution, which is fiddly).
|
||||
# Params: {node_path, animation, target_node, property, keyframes: [{time, value, easing?}]}
|
||||
static func add_value_track(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation", "")
|
||||
var target_node: String = params.get("target_node", "")
|
||||
var property_name: String = params.get("property", "")
|
||||
var keyframes: Array = params.get("keyframes", [])
|
||||
|
||||
if node_path == "" or anim_name == "" or target_node == "" or property_name == "":
|
||||
return _err(-32602, "missing 'node_path', 'animation', 'target_node', or 'property'")
|
||||
if keyframes.is_empty():
|
||||
return _err(-32602, "'keyframes' must contain at least one entry")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer")
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
if not ap.has_animation(anim_name):
|
||||
return _err(-32001, "animation not found on this player: %s" % anim_name)
|
||||
var anim: Animation = ap.get_animation(anim_name)
|
||||
|
||||
# Validate that the target resolves from the scene root (a simpler contract
|
||||
# than AP.root_node-relative — callers think in scene-tree terms already).
|
||||
if root.get_node_or_null(target_node) == null:
|
||||
return _err(-32001, "target_node does not resolve from scene root: %s" % target_node)
|
||||
|
||||
var track_idx: int = anim.add_track(Animation.TYPE_VALUE)
|
||||
anim.track_set_path(track_idx, NodePath("%s:%s" % [target_node, property_name]))
|
||||
|
||||
var max_time: float = 0.0
|
||||
for kf in keyframes:
|
||||
if typeof(kf) != TYPE_DICTIONARY or not kf.has("time") or not kf.has("value"):
|
||||
return _err(-32602, "each keyframe needs {time, value}")
|
||||
var t: float = float(kf.time)
|
||||
var transition: float = float(kf.get("easing", 1.0))
|
||||
anim.track_insert_key(track_idx, t, kf.value, transition)
|
||||
if t > max_time:
|
||||
max_time = t
|
||||
|
||||
if max_time > anim.length:
|
||||
anim.length = max_time
|
||||
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"animation": anim_name,
|
||||
"track_index": track_idx,
|
||||
"target": "%s:%s" % [target_node, property_name],
|
||||
"keyframes_added": keyframes.size(),
|
||||
})
|
||||
|
||||
|
||||
static func _track_type_name(t: int) -> String:
|
||||
match t:
|
||||
Animation.TYPE_VALUE:
|
||||
return "value"
|
||||
Animation.TYPE_POSITION_3D:
|
||||
return "position_3d"
|
||||
Animation.TYPE_ROTATION_3D:
|
||||
return "rotation_3d"
|
||||
Animation.TYPE_SCALE_3D:
|
||||
return "scale_3d"
|
||||
Animation.TYPE_BLEND_SHAPE:
|
||||
return "blend_shape"
|
||||
Animation.TYPE_METHOD:
|
||||
return "method"
|
||||
Animation.TYPE_BEZIER:
|
||||
return "bezier"
|
||||
Animation.TYPE_AUDIO:
|
||||
return "audio"
|
||||
Animation.TYPE_ANIMATION:
|
||||
return "animation"
|
||||
_:
|
||||
return "unknown(%d)" % t
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://kurfdxg67ggk
|
||||
@@ -0,0 +1,296 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# client.* — manage MCP client config files so users don't have to hand-edit them.
|
||||
# Each client has its own config schema; we handle three shapes:
|
||||
# "mcp_servers" → {"mcpServers": {"<name>": {command, args}}}
|
||||
# Used by Claude Code, Claude Desktop, Cursor, Windsurf.
|
||||
# "vscode_servers" → {"servers": {"<name>": {command, args}}}
|
||||
# VS Code's native MCP config.
|
||||
# "continue_experimental" → {"experimental": {"modelContextProtocolServers":
|
||||
# [{"transport": {"type": "stdio", command, args}}]}}
|
||||
# Continue.dev's MCP config shape (array, not dict).
|
||||
#
|
||||
# Clients requiring JSONC (with comments) or non-file storage — Zed, VS Code
|
||||
# user-scope settings.json, Cline — aren't supported yet. Use manual config
|
||||
# for those; see the README per-client section.
|
||||
|
||||
const SERVER_NAME := "godot-agent-tools"
|
||||
const SERVER_COMMAND := "npx"
|
||||
const SERVER_ARGS := ["-y", "godot-agent-tools-mcp"]
|
||||
|
||||
|
||||
static func list_clients(_params: Dictionary) -> Dictionary:
|
||||
var out: Array = []
|
||||
for client_id in _client_ids():
|
||||
var info := _client_info(client_id)
|
||||
if info.has("_error"):
|
||||
out.append({"client": client_id, "error": info._error})
|
||||
continue
|
||||
var res := _read_config(info.path)
|
||||
var cfg: Dictionary = res.cfg
|
||||
var installed := _has_server_entry(cfg, info.format)
|
||||
out.append({
|
||||
"client": client_id,
|
||||
"config_path": info.path,
|
||||
"exists": _path_exists(info.path),
|
||||
"configured": installed,
|
||||
"unparseable": res.unparseable,
|
||||
})
|
||||
return _ok({"clients": out})
|
||||
|
||||
|
||||
static func configure(params: Dictionary) -> Dictionary:
|
||||
var client_id: String = params.get("client", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
if client_id == "":
|
||||
return _err(-32602, "missing 'client' (supported: %s)" % ", ".join(_client_ids()))
|
||||
var info := _client_info(client_id)
|
||||
if info.has("_error"):
|
||||
return _err(-32602, info._error)
|
||||
|
||||
var res := _read_config(info.path)
|
||||
if res.unparseable:
|
||||
return _err(-32001, "existing config is not valid JSON — refusing to overwrite (fix or remove it first): %s" % info.path)
|
||||
var cfg: Dictionary = res.cfg
|
||||
if _has_server_entry(cfg, info.format) and not overwrite:
|
||||
return _ok({"client": client_id, "path": info.path, "status": "already_configured"})
|
||||
|
||||
_set_server_entry(cfg, info.format)
|
||||
var write_err := _write_config(info.path, cfg)
|
||||
if write_err != "":
|
||||
return _err(-32001, write_err)
|
||||
return _ok({"client": client_id, "path": info.path, "status": "configured"})
|
||||
|
||||
|
||||
static func remove(params: Dictionary) -> Dictionary:
|
||||
var client_id: String = params.get("client", "")
|
||||
if client_id == "":
|
||||
return _err(-32602, "missing 'client'")
|
||||
var info := _client_info(client_id)
|
||||
if info.has("_error"):
|
||||
return _err(-32602, info._error)
|
||||
|
||||
var res := _read_config(info.path)
|
||||
if res.unparseable:
|
||||
return _err(-32001, "existing config is not valid JSON — refusing to modify (fix or remove it first): %s" % info.path)
|
||||
var cfg: Dictionary = res.cfg
|
||||
if not _has_server_entry(cfg, info.format):
|
||||
return _ok({"client": client_id, "path": info.path, "status": "not_present"})
|
||||
|
||||
_clear_server_entry(cfg, info.format)
|
||||
var write_err := _write_config(info.path, cfg)
|
||||
if write_err != "":
|
||||
return _err(-32001, write_err)
|
||||
return _ok({"client": client_id, "path": info.path, "status": "removed"})
|
||||
|
||||
|
||||
static func _client_ids() -> Array:
|
||||
return [
|
||||
"claude_code_project",
|
||||
"claude_code_user",
|
||||
"claude_desktop",
|
||||
"cursor_project",
|
||||
"cursor_user",
|
||||
"windsurf_user",
|
||||
"continue_user",
|
||||
"vscode_project",
|
||||
]
|
||||
|
||||
|
||||
static func _client_info(client_id: String) -> Dictionary:
|
||||
var home := OS.get_environment("USERPROFILE")
|
||||
if home == "":
|
||||
home = OS.get_environment("HOME")
|
||||
if home == "":
|
||||
return {"_error": "cannot resolve home directory (neither USERPROFILE nor HOME set)"}
|
||||
|
||||
match client_id:
|
||||
"claude_code_project":
|
||||
return {"path": ProjectSettings.globalize_path("res://.mcp.json"), "format": "mcp_servers"}
|
||||
"claude_code_user":
|
||||
return {"path": home.path_join(".claude.json"), "format": "mcp_servers"}
|
||||
"cursor_project":
|
||||
return {"path": ProjectSettings.globalize_path("res://.cursor/mcp.json"), "format": "mcp_servers"}
|
||||
"cursor_user":
|
||||
return {"path": home.path_join(".cursor").path_join("mcp.json"), "format": "mcp_servers"}
|
||||
"windsurf_user":
|
||||
return {"path": home.path_join(".codeium").path_join("windsurf").path_join("mcp_config.json"), "format": "mcp_servers"}
|
||||
"continue_user":
|
||||
return {"path": home.path_join(".continue").path_join("config.json"), "format": "continue_experimental"}
|
||||
"vscode_project":
|
||||
return {"path": ProjectSettings.globalize_path("res://.vscode/mcp.json"), "format": "vscode_servers"}
|
||||
"claude_desktop":
|
||||
var os_name := OS.get_name()
|
||||
var path: String
|
||||
if os_name == "macOS":
|
||||
path = home.path_join("Library").path_join("Application Support").path_join("Claude").path_join("claude_desktop_config.json")
|
||||
elif os_name == "Windows":
|
||||
var appdata := OS.get_environment("APPDATA")
|
||||
if appdata == "":
|
||||
return {"_error": "APPDATA env var not set on Windows"}
|
||||
path = appdata.path_join("Claude").path_join("claude_desktop_config.json")
|
||||
else:
|
||||
path = home.path_join(".config").path_join("Claude").path_join("claude_desktop_config.json")
|
||||
return {"path": path, "format": "mcp_servers"}
|
||||
_:
|
||||
return {"_error": "unknown client: %s (supported: %s)" % [client_id, ", ".join(_client_ids())]}
|
||||
|
||||
|
||||
static func _path_exists(path: String) -> bool:
|
||||
return FileAccess.file_exists(path)
|
||||
|
||||
|
||||
# Returns {"cfg": Dictionary, "unparseable": bool}. unparseable=true means the
|
||||
# file exists with non-empty content that is NOT a valid JSON object — callers
|
||||
# that go on to WRITE must abort, since merging into {} would wipe the user's
|
||||
# data (e.g. a large ~/.claude.json with a transient syntax error, or a JSONC
|
||||
# file with comments that JSON.parse_string can't handle).
|
||||
static func _read_config(path: String) -> Dictionary:
|
||||
var content := ""
|
||||
if _path_exists(path):
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f:
|
||||
content = f.get_as_text()
|
||||
f.close()
|
||||
if content.strip_edges() == "":
|
||||
return {"cfg": {}, "unparseable": false}
|
||||
var parsed = JSON.parse_string(content)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return {"cfg": {}, "unparseable": true}
|
||||
return {"cfg": parsed, "unparseable": false}
|
||||
|
||||
|
||||
static func _write_config(path: String, cfg: Dictionary) -> String:
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return "mkdir failed for %s (%d)" % [dir_path, derr]
|
||||
# Back up any existing non-empty file before overwriting, so a mistaken merge
|
||||
# or an interrupted write is recoverable.
|
||||
if FileAccess.file_exists(path):
|
||||
var prev := FileAccess.open(path, FileAccess.READ)
|
||||
if prev:
|
||||
var prev_content := prev.get_as_text()
|
||||
prev.close()
|
||||
if prev_content.strip_edges() != "":
|
||||
var bak := FileAccess.open(path + ".bak", FileAccess.WRITE)
|
||||
if bak:
|
||||
bak.store_string(prev_content)
|
||||
bak.close()
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return "could not open for write: %s (error %d)" % [path, FileAccess.get_open_error()]
|
||||
f.store_string(JSON.stringify(cfg, " "))
|
||||
f.close()
|
||||
return ""
|
||||
|
||||
|
||||
# --- Format-specific readers/writers -----------------------------------------
|
||||
|
||||
static func _has_server_entry(cfg: Dictionary, format: String) -> bool:
|
||||
match format:
|
||||
"mcp_servers":
|
||||
var servers = cfg.get("mcpServers")
|
||||
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
||||
"vscode_servers":
|
||||
var servers = cfg.get("servers")
|
||||
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
||||
"continue_experimental":
|
||||
var exp = cfg.get("experimental")
|
||||
if typeof(exp) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var arr = exp.get("modelContextProtocolServers")
|
||||
if typeof(arr) != TYPE_ARRAY:
|
||||
return false
|
||||
for entry in arr:
|
||||
if typeof(entry) == TYPE_DICTIONARY and _entry_matches_continue(entry):
|
||||
return true
|
||||
return false
|
||||
_:
|
||||
return false
|
||||
|
||||
|
||||
static func _set_server_entry(cfg: Dictionary, format: String) -> void:
|
||||
match format:
|
||||
"mcp_servers":
|
||||
if typeof(cfg.get("mcpServers")) != TYPE_DICTIONARY:
|
||||
cfg["mcpServers"] = {}
|
||||
(cfg["mcpServers"] as Dictionary)[SERVER_NAME] = {
|
||||
"command": SERVER_COMMAND,
|
||||
"args": SERVER_ARGS.duplicate(),
|
||||
}
|
||||
"vscode_servers":
|
||||
if typeof(cfg.get("servers")) != TYPE_DICTIONARY:
|
||||
cfg["servers"] = {}
|
||||
(cfg["servers"] as Dictionary)[SERVER_NAME] = {
|
||||
"command": SERVER_COMMAND,
|
||||
"args": SERVER_ARGS.duplicate(),
|
||||
}
|
||||
"continue_experimental":
|
||||
if typeof(cfg.get("experimental")) != TYPE_DICTIONARY:
|
||||
cfg["experimental"] = {}
|
||||
var exp: Dictionary = cfg["experimental"]
|
||||
if typeof(exp.get("modelContextProtocolServers")) != TYPE_ARRAY:
|
||||
exp["modelContextProtocolServers"] = []
|
||||
var arr: Array = exp["modelContextProtocolServers"]
|
||||
# Remove any existing match so we don't duplicate.
|
||||
var kept: Array = []
|
||||
for entry in arr:
|
||||
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
||||
kept.append(entry)
|
||||
kept.append({
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": SERVER_COMMAND,
|
||||
"args": SERVER_ARGS.duplicate(),
|
||||
},
|
||||
})
|
||||
exp["modelContextProtocolServers"] = kept
|
||||
|
||||
|
||||
static func _clear_server_entry(cfg: Dictionary, format: String) -> void:
|
||||
match format:
|
||||
"mcp_servers":
|
||||
var servers = cfg.get("mcpServers")
|
||||
if typeof(servers) == TYPE_DICTIONARY:
|
||||
(servers as Dictionary).erase(SERVER_NAME)
|
||||
"vscode_servers":
|
||||
var servers = cfg.get("servers")
|
||||
if typeof(servers) == TYPE_DICTIONARY:
|
||||
(servers as Dictionary).erase(SERVER_NAME)
|
||||
"continue_experimental":
|
||||
var exp = cfg.get("experimental")
|
||||
if typeof(exp) != TYPE_DICTIONARY:
|
||||
return
|
||||
var arr = exp.get("modelContextProtocolServers")
|
||||
if typeof(arr) != TYPE_ARRAY:
|
||||
return
|
||||
var kept: Array = []
|
||||
for entry in arr:
|
||||
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
||||
kept.append(entry)
|
||||
exp["modelContextProtocolServers"] = kept
|
||||
|
||||
|
||||
# Continue's config uses a flat array with no `name` field — identify our entry
|
||||
# by the transport command+args combo.
|
||||
static func _entry_matches_continue(entry: Dictionary) -> bool:
|
||||
var transport = entry.get("transport")
|
||||
if typeof(transport) != TYPE_DICTIONARY:
|
||||
return false
|
||||
if transport.get("command") != SERVER_COMMAND:
|
||||
return false
|
||||
var a = transport.get("args")
|
||||
if typeof(a) != TYPE_ARRAY or (a as Array).size() < 2:
|
||||
return false
|
||||
return a[1] == SERVER_ARGS[1]
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3u1el0113l81
|
||||
@@ -0,0 +1,72 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# docs.class_ref — return the public API of a Godot class so the agent can plan
|
||||
# work without guessing method/property/signal names.
|
||||
# Params: {class_name, include_inherited?: false}
|
||||
# Returns: {class, parent, methods: [{name, args, return}], properties, signals, constants}
|
||||
static func class_ref(params: Dictionary) -> Dictionary:
|
||||
var cname: String = params.get("class_name", "")
|
||||
var include_inherited: bool = params.get("include_inherited", false)
|
||||
if cname == "":
|
||||
return _err(-32602, "missing 'class_name'")
|
||||
if not ClassDB.class_exists(cname):
|
||||
return _err(-32602, "unknown class: %s" % cname)
|
||||
|
||||
var no_inheritance := not include_inherited
|
||||
|
||||
var methods: Array = []
|
||||
for m in ClassDB.class_get_method_list(cname, no_inheritance):
|
||||
# Skip internal / editor-only methods — they're noise for an agent.
|
||||
if (m.flags as int) & METHOD_FLAG_OBJECT_CORE:
|
||||
continue
|
||||
var args: Array = []
|
||||
for a in m.args:
|
||||
args.append({"name": a.name, "type": type_string(a.type)})
|
||||
methods.append({
|
||||
"name": m.name,
|
||||
"args": args,
|
||||
"return": type_string(m.return.type) if m.has("return") else "void",
|
||||
"virtual": bool((m.flags as int) & METHOD_FLAG_VIRTUAL),
|
||||
"static": bool((m.flags as int) & METHOD_FLAG_STATIC),
|
||||
})
|
||||
|
||||
var properties: Array = []
|
||||
for p in ClassDB.class_get_property_list(cname, no_inheritance):
|
||||
# Only user-facing properties (storage + editor). Internal group markers have usage=0.
|
||||
if not ((p.usage as int) & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
properties.append({
|
||||
"name": p.name,
|
||||
"type": type_string(p.type),
|
||||
"hint": p.get("hint_string", ""),
|
||||
})
|
||||
|
||||
var signals: Array = []
|
||||
for s in ClassDB.class_get_signal_list(cname, no_inheritance):
|
||||
var args: Array = []
|
||||
for a in s.args:
|
||||
args.append({"name": a.name, "type": type_string(a.type)})
|
||||
signals.append({"name": s.name, "args": args})
|
||||
|
||||
var constants: Array = []
|
||||
for c in ClassDB.class_get_integer_constant_list(cname, no_inheritance):
|
||||
constants.append({"name": c, "value": ClassDB.class_get_integer_constant(cname, c)})
|
||||
|
||||
return _ok({
|
||||
"class": cname,
|
||||
"parent": ClassDB.get_parent_class(cname),
|
||||
"inherited_included": include_inherited,
|
||||
"methods": methods,
|
||||
"properties": properties,
|
||||
"signals": signals,
|
||||
"constants": constants,
|
||||
})
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwarklg56vsim
|
||||
@@ -0,0 +1,201 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# editor.reload_filesystem — trigger an editor filesystem rescan.
|
||||
# Run this after external file changes (created/moved/deleted by tools outside the editor)
|
||||
# so load() and the FileSystem dock reflect reality.
|
||||
static func reload_filesystem(_params: Dictionary) -> Dictionary:
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return {"data": {"scanned": true}}
|
||||
|
||||
|
||||
# editor.save_all_scenes — save every open edited scene.
|
||||
static func save_all_scenes(_params: Dictionary) -> Dictionary:
|
||||
EditorInterface.save_all_scenes()
|
||||
return {"data": {"saved": true}}
|
||||
|
||||
|
||||
# editor.state — consolidated editor + project status in one call.
|
||||
# Replaces piecewise scene.current + project.get_setting + etc.
|
||||
static func state(_params: Dictionary) -> Dictionary:
|
||||
var version := Engine.get_version_info()
|
||||
var version_str: String = "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status]
|
||||
|
||||
var current_scene_path := ""
|
||||
var current_scene_class := ""
|
||||
var current_scene_name := ""
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root != null:
|
||||
current_scene_path = root.scene_file_path
|
||||
current_scene_class = root.get_class()
|
||||
current_scene_name = String(root.name)
|
||||
|
||||
var open_scenes: Array = []
|
||||
for s in EditorInterface.get_open_scenes():
|
||||
open_scenes.append(s)
|
||||
|
||||
var project_name := ""
|
||||
if ProjectSettings.has_setting("application/config/name"):
|
||||
project_name = str(ProjectSettings.get_setting("application/config/name"))
|
||||
|
||||
return {"data": {
|
||||
"godot_version": version_str,
|
||||
"project_name": project_name,
|
||||
"current_scene": {
|
||||
"path": current_scene_path,
|
||||
"root_name": current_scene_name,
|
||||
"root_class": current_scene_class,
|
||||
"open": root != null,
|
||||
},
|
||||
"open_scenes": open_scenes,
|
||||
"playing_scene": EditorInterface.is_playing_scene(),
|
||||
"playing_scene_path": EditorInterface.get_playing_scene() if EditorInterface.is_playing_scene() else "",
|
||||
}}
|
||||
|
||||
|
||||
# editor.selection_get — return the currently-selected nodes in the editor tree dock.
|
||||
# Useful for "operate on whatever I clicked" workflows.
|
||||
static func selection_get(_params: Dictionary) -> Dictionary:
|
||||
var selection := EditorInterface.get_selection()
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
var out: Array = []
|
||||
for node in selection.get_selected_nodes():
|
||||
var path_str := ""
|
||||
if root != null and (node == root or root.is_ancestor_of(node)):
|
||||
path_str = String(root.get_path_to(node))
|
||||
out.append({
|
||||
"name": String(node.name),
|
||||
"class": node.get_class(),
|
||||
"node_path": path_str,
|
||||
})
|
||||
return {"data": {"selected": out, "count": out.size()}}
|
||||
|
||||
|
||||
# editor.selection_set — select a specific set of nodes in the editor tree dock.
|
||||
# Params: {node_paths: ["Player", "Enemy"]}
|
||||
static func selection_set(params: Dictionary) -> Dictionary:
|
||||
var paths: Array = params.get("node_paths", [])
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return {"error": {"code": -32001, "message": "no scene open"}}
|
||||
|
||||
var selection := EditorInterface.get_selection()
|
||||
selection.clear()
|
||||
|
||||
var resolved: Array = []
|
||||
var missing: Array = []
|
||||
for p in paths:
|
||||
var node: Node = root if p == "." else root.get_node_or_null(p)
|
||||
if node == null:
|
||||
missing.append(p)
|
||||
continue
|
||||
selection.add_node(node)
|
||||
resolved.append(p)
|
||||
|
||||
if not missing.is_empty():
|
||||
return {"error": {"code": -32001, "message": "nodes not found: %s" % str(missing)}}
|
||||
return {"data": {"selected": resolved}}
|
||||
|
||||
|
||||
# editor.game_screenshot — capture the viewport of the currently-running game.
|
||||
# Works when the user has pressed F5 (or equivalent) and a game process is running
|
||||
# with our runtime bridge autoload registered. Writes a request file, polls for
|
||||
# the bridge to produce the PNG, returns the path.
|
||||
#
|
||||
# Params: {output?: "res://.godot/agent_tools/game.png", timeout_ms?: 5000}
|
||||
static func game_screenshot(params: Dictionary) -> Dictionary:
|
||||
var output_path: String = params.get("output", "res://.godot/agent_tools/game_screenshot.png")
|
||||
var timeout_ms: int = int(params.get("timeout_ms", 5000))
|
||||
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return {"error": {"code": -32001,
|
||||
"message": "no game running — press F5 (or equivalent) to start the game, then call again. Alternative for arbitrary scenes: run.scene_headless with 'screenshot'."}}
|
||||
|
||||
var bridge_dir := "res://.godot/agent_tools/bridge"
|
||||
if not DirAccess.dir_exists_absolute(bridge_dir):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(bridge_dir)
|
||||
if derr != OK:
|
||||
return {"error": {"code": -32001, "message": "mkdir failed: %d" % derr}}
|
||||
|
||||
# Wipe stale output so we can detect when the bridge has written fresh data.
|
||||
if FileAccess.file_exists(output_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(output_path)
|
||||
|
||||
var request_path := bridge_dir + "/request.json"
|
||||
var request_id := str(Time.get_ticks_msec())
|
||||
var req := {
|
||||
"id": request_id,
|
||||
"type": "screenshot",
|
||||
"output": output_path,
|
||||
}
|
||||
var f := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return {"error": {"code": -32001, "message": "could not write bridge request"}}
|
||||
f.store_string(JSON.stringify(req))
|
||||
f.close()
|
||||
|
||||
# Poll for response — bridge writes response.json with matching id when done.
|
||||
var response_path := bridge_dir + "/response.json"
|
||||
var deadline_usec: int = Time.get_ticks_usec() + timeout_ms * 1000
|
||||
while Time.get_ticks_usec() < deadline_usec:
|
||||
if FileAccess.file_exists(response_path):
|
||||
var rf := FileAccess.open(response_path, FileAccess.READ)
|
||||
if rf:
|
||||
var body := rf.get_as_text()
|
||||
rf.close()
|
||||
var parsed = JSON.parse_string(body)
|
||||
if typeof(parsed) == TYPE_DICTIONARY and parsed.get("id", "") == request_id:
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(response_path)
|
||||
if parsed.has("error"):
|
||||
return {"error": {"code": -32001, "message": "bridge: %s" % parsed.error}}
|
||||
return {"data": {
|
||||
"path": output_path,
|
||||
"captured": FileAccess.file_exists(output_path),
|
||||
}}
|
||||
OS.delay_msec(50)
|
||||
|
||||
# Clean up stale request if bridge never picked it up.
|
||||
if FileAccess.file_exists(request_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return {"error": {"code": -32001,
|
||||
"message": "bridge timed out after %dms — is the running game using the _MCPGameBridge autoload? Re-enable the plugin to re-register it, then press F5 again." % timeout_ms}}
|
||||
|
||||
|
||||
# logs.read — pull recent log lines captured by the game-bridge autoload.
|
||||
# Params: {clear?: false, max_lines?: 200}
|
||||
static func logs_read(params: Dictionary) -> Dictionary:
|
||||
var clear: bool = params.get("clear", false)
|
||||
var max_lines: int = int(params.get("max_lines", 200))
|
||||
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
|
||||
if not FileAccess.file_exists(logs_path):
|
||||
return {"data": {
|
||||
"entries": [],
|
||||
"count": 0,
|
||||
"note": "no log buffer found — the game may not be running or the autoload isn't initialized. Press F5 and try again.",
|
||||
}}
|
||||
var f := FileAccess.open(logs_path, FileAccess.READ)
|
||||
if f == null:
|
||||
return {"error": {"code": -32001, "message": "could not read logs"}}
|
||||
var parsed = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
if typeof(parsed) != TYPE_ARRAY:
|
||||
parsed = []
|
||||
var entries: Array = parsed
|
||||
if entries.size() > max_lines:
|
||||
entries = entries.slice(entries.size() - max_lines)
|
||||
if clear:
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(logs_path)
|
||||
return {"data": {"entries": entries, "count": entries.size()}}
|
||||
|
||||
|
||||
# logs.clear — drop the log buffer regardless of read state.
|
||||
static func logs_clear(_params: Dictionary) -> Dictionary:
|
||||
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
|
||||
if FileAccess.file_exists(logs_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(logs_path)
|
||||
return {"data": {"cleared": true}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgoufu3l40rvx
|
||||
@@ -0,0 +1,144 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# fs.list — enumerate project files by type, with optional glob filter.
|
||||
# Params: {type?: "all"|"scene"|"script"|"resource"|"shader"|"image"|"audio",
|
||||
# glob?: "res://scenes/**/*.tscn",
|
||||
# include_addons?: false}
|
||||
# Returns: {type, count, files: [paths]}
|
||||
static func list(params: Dictionary) -> Dictionary:
|
||||
var type_filter: String = params.get("type", "all")
|
||||
var glob: String = params.get("glob", "")
|
||||
var include_addons: bool = params.get("include_addons", false)
|
||||
|
||||
var exts: Array
|
||||
match type_filter:
|
||||
"", "all":
|
||||
exts = ["gd", "cs", "tscn", "tres", "res", "gdshader", "gdshaderinc",
|
||||
"png", "jpg", "jpeg", "svg", "webp",
|
||||
"ogg", "wav", "mp3",
|
||||
"json", "cfg"]
|
||||
"scene":
|
||||
exts = ["tscn"]
|
||||
"script":
|
||||
exts = ["gd", "cs"]
|
||||
"resource":
|
||||
exts = ["tres", "res"]
|
||||
"shader":
|
||||
exts = ["gdshader", "gdshaderinc"]
|
||||
"image":
|
||||
exts = ["png", "jpg", "jpeg", "svg", "webp"]
|
||||
"audio":
|
||||
exts = ["ogg", "wav", "mp3"]
|
||||
_:
|
||||
return _err(-32602, "unknown type: %s (use all|scene|script|resource|shader|image|audio)" % type_filter)
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", exts, files, include_addons)
|
||||
|
||||
if glob != "":
|
||||
var filtered: Array = []
|
||||
for f in files:
|
||||
if f.matchn(glob): # case-insensitive glob match
|
||||
filtered.append(f)
|
||||
files = filtered
|
||||
|
||||
files.sort()
|
||||
return _ok({
|
||||
"type": type_filter,
|
||||
"count": files.size(),
|
||||
"files": files,
|
||||
})
|
||||
|
||||
|
||||
# fs.read_text — read a text file under res://. Complement to user_fs.read.
|
||||
# Params: {path}
|
||||
static func read_text(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
if path == "":
|
||||
return _err(-32602, "missing 'path'")
|
||||
if not path.begins_with("res://"):
|
||||
return _err(-32602, "path must begin with 'res://' (use user_fs.read for user:// files)")
|
||||
if not _within_sandbox(path, "res://"):
|
||||
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return _err(-32001, "failed to open %s (error %d)" % [path, FileAccess.get_open_error()])
|
||||
var size_bytes := f.get_length()
|
||||
var content := f.get_as_text()
|
||||
f.close()
|
||||
return _ok({"path": path, "content": content, "size_bytes": size_bytes})
|
||||
|
||||
|
||||
# fs.write_text — write a text file under res://. Creates parent directories
|
||||
# if needed. Triggers the editor's filesystem rescan so the new file shows up
|
||||
# in the FileSystem dock.
|
||||
# Params: {path, content, overwrite?: false}
|
||||
static func write_text(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var content: String = str(params.get("content", ""))
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
if path == "":
|
||||
return _err(-32602, "missing 'path'")
|
||||
if not path.begins_with("res://"):
|
||||
return _err(-32602, "path must begin with 'res://'")
|
||||
if not _within_sandbox(path, "res://"):
|
||||
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
|
||||
if FileAccess.file_exists(path) and not overwrite:
|
||||
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
|
||||
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return _err(-32001, "could not open for write: %s" % path)
|
||||
f.store_string(content)
|
||||
f.close()
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
return _ok({"path": path, "bytes_written": content.length()})
|
||||
|
||||
|
||||
static func _walk(dir_path: String, exts: Array, out: Array, include_addons: bool) -> void:
|
||||
var d := DirAccess.open(dir_path)
|
||||
if d == null:
|
||||
return
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var name := d.get_next()
|
||||
if name == "":
|
||||
break
|
||||
if name.begins_with("."):
|
||||
continue
|
||||
var full := dir_path.path_join(name)
|
||||
if d.current_is_dir():
|
||||
if not include_addons and full == "res://addons/agent_tools":
|
||||
continue # always skip our own addon
|
||||
_walk(full, exts, out, include_addons)
|
||||
else:
|
||||
for e in exts:
|
||||
if name.ends_with("." + e):
|
||||
out.append(full)
|
||||
break
|
||||
d.list_dir_end()
|
||||
|
||||
|
||||
# Path-traversal guard. `begins_with("res://")` is NOT a sandbox — Godot resolves
|
||||
# `..` segments against the real filesystem, so "res://../../etc/x" escapes the
|
||||
# project. Globalize to an absolute path, collapse `..`, and confirm the result
|
||||
# stays inside the (globalized, collapsed) prefix root.
|
||||
static func _within_sandbox(path: String, prefix: String) -> bool:
|
||||
var root := ProjectSettings.globalize_path(prefix).simplify_path().trim_suffix("/")
|
||||
var abs := ProjectSettings.globalize_path(path).simplify_path()
|
||||
return abs == root or abs.begins_with(root + "/")
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxq0mo7kx65wd
|
||||
@@ -0,0 +1,243 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# input_map.add_action — register a new input action in project.godot.
|
||||
# Params: {name, deadzone?: 0.5}
|
||||
static func add_action(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("name", "")
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'name'")
|
||||
var deadzone: float = float(params.get("deadzone", 0.5))
|
||||
var key := "input/" + action_name
|
||||
if ProjectSettings.has_setting(key):
|
||||
return _err(-32602, "action already exists: %s" % action_name)
|
||||
ProjectSettings.set_setting(key, {
|
||||
"deadzone": deadzone,
|
||||
"events": [],
|
||||
})
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
# Keep the runtime InputMap in sync with the on-disk project settings —
|
||||
# ProjectSettings.save() only writes the file; it doesn't register actions
|
||||
# with InputMap until editor restart.
|
||||
if not InputMap.has_action(action_name):
|
||||
InputMap.add_action(action_name, deadzone)
|
||||
return _ok({"added": action_name})
|
||||
|
||||
|
||||
# input_map.add_event — attach an input event to an existing action.
|
||||
# Supported event shapes (pick one via 'type'):
|
||||
# {type: "key", keycode: "A" | "Space" | "F1" ..., physical?: true}
|
||||
# {type: "mouse_button", button_index: 1 (left) | 2 (right) | 3 (middle)}
|
||||
# {type: "joy_button", button_index: 0..}
|
||||
# Params: {action, event: <shape above>}
|
||||
static func add_event(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("action", "")
|
||||
var event_spec: Dictionary = params.get("event", {})
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'action'")
|
||||
if event_spec.is_empty():
|
||||
return _err(-32602, "missing 'event'")
|
||||
var key := "input/" + action_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "action not found: %s" % action_name)
|
||||
|
||||
var event := _build_event(event_spec)
|
||||
if event == null:
|
||||
return _err(-32602, "invalid event spec — see tool description for supported shapes")
|
||||
|
||||
var current: Dictionary = ProjectSettings.get_setting(key)
|
||||
var events_arr: Array = current.get("events", [])
|
||||
events_arr.append(event)
|
||||
current["events"] = events_arr
|
||||
ProjectSettings.set_setting(key, current)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
# Sync InputMap so Input.is_action_pressed() etc. see the event without restart.
|
||||
if InputMap.has_action(action_name):
|
||||
InputMap.action_add_event(action_name, event)
|
||||
return _ok({"action": action_name, "event": event_spec})
|
||||
|
||||
|
||||
# input_map.list — return registered input actions with their events.
|
||||
# Params: {include_builtins?: false}
|
||||
# Defaults to user-defined only (parses the [input] section of project.godot,
|
||||
# which contains only user overrides — Godot's ~90 ui_* builtins live in-memory).
|
||||
# When include_builtins, falls back to InputMap which has the full union.
|
||||
static func list_actions(params: Dictionary) -> Dictionary:
|
||||
var include_builtins: bool = params.get("include_builtins", false)
|
||||
var items: Array = []
|
||||
|
||||
if include_builtins:
|
||||
for a in InputMap.get_actions():
|
||||
var described: Array = []
|
||||
for e in InputMap.action_get_events(a):
|
||||
described.append(_describe_event(e))
|
||||
items.append({
|
||||
"name": String(a),
|
||||
"deadzone": InputMap.action_get_deadzone(a),
|
||||
"events": described,
|
||||
})
|
||||
else:
|
||||
# Read straight from project.godot so newly-added actions show up even
|
||||
# if they haven't been registered with the runtime InputMap yet.
|
||||
var cf := ConfigFile.new()
|
||||
var err := cf.load("res://project.godot")
|
||||
if err == OK and cf.has_section("input"):
|
||||
for action_name in cf.get_section_keys("input"):
|
||||
var value: Dictionary = cf.get_value("input", action_name)
|
||||
var described: Array = []
|
||||
for e in value.get("events", []):
|
||||
described.append(_describe_event(e))
|
||||
items.append({
|
||||
"name": action_name,
|
||||
"deadzone": value.get("deadzone", 0.5),
|
||||
"events": described,
|
||||
})
|
||||
|
||||
return _ok({"actions": items})
|
||||
|
||||
|
||||
# input_map.remove_event — remove an event from an action by index.
|
||||
# Call input_map.list first to see indices; events are listed in the order they
|
||||
# were added.
|
||||
# Params: {action, event_index}
|
||||
static func remove_event(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("action", "")
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'action'")
|
||||
if not params.has("event_index"):
|
||||
return _err(-32602, "missing 'event_index'")
|
||||
var idx: int = int(params.event_index)
|
||||
var key := "input/" + action_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "action not found: %s" % action_name)
|
||||
var current: Dictionary = ProjectSettings.get_setting(key)
|
||||
var events_arr: Array = current.get("events", [])
|
||||
if idx < 0 or idx >= events_arr.size():
|
||||
return _err(-32602, "event_index %d out of range (0..%d)" % [idx, events_arr.size() - 1])
|
||||
var removed_event = events_arr[idx]
|
||||
events_arr.remove_at(idx)
|
||||
current["events"] = events_arr
|
||||
ProjectSettings.set_setting(key, current)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
if InputMap.has_action(action_name) and removed_event != null:
|
||||
InputMap.action_erase_event(action_name, removed_event)
|
||||
return _ok({"action": action_name, "removed_index": idx})
|
||||
|
||||
|
||||
# input_map.remove_action — delete a user-registered action.
|
||||
static func remove_action(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("name", "")
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'name'")
|
||||
var key := "input/" + action_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "action not found: %s" % action_name)
|
||||
ProjectSettings.clear(key)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
if InputMap.has_action(action_name):
|
||||
InputMap.erase_action(action_name)
|
||||
return _ok({"removed": action_name})
|
||||
|
||||
|
||||
static func _build_event(spec: Dictionary) -> InputEvent:
|
||||
# device defaults to -1 (ALL_DEVICES) matching the editor's "All Devices" default.
|
||||
# For local multiplayer bind device=0, device=1, etc. to distinguish controllers.
|
||||
var device: int = int(spec.get("device", -1))
|
||||
match spec.get("type", ""):
|
||||
"key":
|
||||
var e := InputEventKey.new()
|
||||
e.device = device
|
||||
var kc_raw = spec.get("keycode", "")
|
||||
var keycode := 0
|
||||
if kc_raw is int:
|
||||
keycode = kc_raw
|
||||
elif kc_raw is String:
|
||||
keycode = OS.find_keycode_from_string(kc_raw)
|
||||
if keycode == 0:
|
||||
return null
|
||||
if spec.get("physical", true):
|
||||
e.physical_keycode = keycode
|
||||
else:
|
||||
e.keycode = keycode
|
||||
return e
|
||||
"mouse_button":
|
||||
var e := InputEventMouseButton.new()
|
||||
e.device = device
|
||||
e.button_index = int(spec.get("button_index", 1))
|
||||
return e
|
||||
"joy_button":
|
||||
var e := InputEventJoypadButton.new()
|
||||
e.device = device
|
||||
e.button_index = int(spec.get("button_index", 0))
|
||||
return e
|
||||
"joy_motion":
|
||||
# axis accepts int 0..5 or one of:
|
||||
# "left_x" / "left_y" / "right_x" / "right_y" / "trigger_left" / "trigger_right"
|
||||
# axis_value: -1.0 (full negative) to 1.0 (full positive); sign picks the direction
|
||||
# that triggers the action.
|
||||
var e := InputEventJoypadMotion.new()
|
||||
e.device = device
|
||||
e.axis = _coerce_axis(spec.get("axis", 0))
|
||||
e.axis_value = float(spec.get("axis_value", 1.0))
|
||||
return e
|
||||
_:
|
||||
return null
|
||||
|
||||
|
||||
static func _coerce_axis(v) -> int:
|
||||
if v is int:
|
||||
return v
|
||||
if v is String:
|
||||
match v:
|
||||
"left_x":
|
||||
return JOY_AXIS_LEFT_X
|
||||
"left_y":
|
||||
return JOY_AXIS_LEFT_Y
|
||||
"right_x":
|
||||
return JOY_AXIS_RIGHT_X
|
||||
"right_y":
|
||||
return JOY_AXIS_RIGHT_Y
|
||||
"trigger_left":
|
||||
return JOY_AXIS_TRIGGER_LEFT
|
||||
"trigger_right":
|
||||
return JOY_AXIS_TRIGGER_RIGHT
|
||||
return 0
|
||||
|
||||
|
||||
static func _describe_event(e) -> Dictionary:
|
||||
if e is InputEventKey:
|
||||
var kc: int = e.physical_keycode if e.physical_keycode != 0 else e.keycode
|
||||
return {
|
||||
"type": "key",
|
||||
"keycode": OS.get_keycode_string(kc),
|
||||
"physical": e.physical_keycode != 0,
|
||||
"device": e.device,
|
||||
}
|
||||
if e is InputEventMouseButton:
|
||||
return {"type": "mouse_button", "button_index": e.button_index, "device": e.device}
|
||||
if e is InputEventJoypadButton:
|
||||
return {"type": "joy_button", "button_index": e.button_index, "device": e.device}
|
||||
if e is InputEventJoypadMotion:
|
||||
return {
|
||||
"type": "joy_motion",
|
||||
"axis": e.axis,
|
||||
"axis_value": e.axis_value,
|
||||
"device": e.device,
|
||||
}
|
||||
return {"type": "unknown", "class": (e as Object).get_class() if e else "null"}
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dp1m17i4hu7t1
|
||||
@@ -0,0 +1,56 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# performance.monitors — read Godot's built-in performance monitors.
|
||||
# Params: {monitors?: [string]} — specific monitor names, default = common set
|
||||
# Returns: {fps, frame_time_ms, process_time_ms, physics_time_ms, mem_static_mb,
|
||||
# mem_dynamic_mb, objects, resources, nodes, draw_calls, ...}
|
||||
static func monitors(params: Dictionary) -> Dictionary:
|
||||
var requested: Array = params.get("monitors", [])
|
||||
var out: Dictionary = {}
|
||||
|
||||
# Default set — the monitors agents actually want for quick perf snapshots.
|
||||
if requested.is_empty():
|
||||
out["fps"] = Performance.get_monitor(Performance.TIME_FPS)
|
||||
out["frame_time_ms"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||
out["physics_time_ms"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||
out["mem_static_mb"] = Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0
|
||||
out["mem_static_max_mb"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX) / 1048576.0
|
||||
out["object_count"] = int(Performance.get_monitor(Performance.OBJECT_COUNT))
|
||||
out["resource_count"] = int(Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT))
|
||||
out["node_count"] = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
out["orphan_node_count"] = int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT))
|
||||
out["draw_calls"] = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
|
||||
else:
|
||||
for name in requested:
|
||||
var enum_value := _resolve_monitor_name(String(name))
|
||||
if enum_value == -1:
|
||||
out[name] = null
|
||||
else:
|
||||
out[name] = Performance.get_monitor(enum_value)
|
||||
|
||||
return _ok({"monitors": out})
|
||||
|
||||
|
||||
# Map friendly strings to Performance.* enum values so callers don't have to
|
||||
# remember the integer IDs.
|
||||
static func _resolve_monitor_name(name: String) -> int:
|
||||
match name:
|
||||
"fps": return Performance.TIME_FPS
|
||||
"frame_time": return Performance.TIME_PROCESS
|
||||
"physics_time": return Performance.TIME_PHYSICS_PROCESS
|
||||
"mem_static": return Performance.MEMORY_STATIC
|
||||
"mem_static_max": return Performance.MEMORY_STATIC_MAX
|
||||
"objects": return Performance.OBJECT_COUNT
|
||||
"resources": return Performance.OBJECT_RESOURCE_COUNT
|
||||
"nodes": return Performance.OBJECT_NODE_COUNT
|
||||
"orphan_nodes": return Performance.OBJECT_ORPHAN_NODE_COUNT
|
||||
"draw_calls": return Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME
|
||||
"audio_latency": return Performance.AUDIO_OUTPUT_LATENCY
|
||||
"physics_2d_active_objects": return Performance.PHYSICS_2D_ACTIVE_OBJECTS
|
||||
"physics_3d_active_objects": return Performance.PHYSICS_3D_ACTIVE_OBJECTS
|
||||
_: return -1
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bijue5qgitvnk
|
||||
@@ -0,0 +1,139 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# physics.autofit_collision_shape_2d — compute a CollisionShape2D sized to a
|
||||
# sibling/child Sprite2D or AnimatedSprite2D's visual bounds. Common pattern
|
||||
# that's tedious to set up by hand: you have a sprite, you want a rectangle or
|
||||
# circle collider that roughly matches it.
|
||||
#
|
||||
# Params: {
|
||||
# node_path: CollisionShape2D to fit (created if missing when create:true)
|
||||
# source: NodePath to a Sprite2D / AnimatedSprite2D (auto-detected
|
||||
# among siblings if omitted)
|
||||
# shape: "rectangle" | "circle" | "capsule" (default "rectangle")
|
||||
# margin: float pixels to shrink by (default 0)
|
||||
# create: bool — if true and node_path doesn't exist, create it as
|
||||
# a child of the source's parent. Default false.
|
||||
# }
|
||||
static func autofit_collision_shape_2d(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var source_path: String = params.get("source", "")
|
||||
var shape_type: String = params.get("shape", "rectangle")
|
||||
var margin: float = float(params.get("margin", 0.0))
|
||||
var create: bool = params.get("create", false)
|
||||
|
||||
if node_path == "":
|
||||
return _err(-32602, "missing 'node_path'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
|
||||
var target: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if target == null and not create:
|
||||
return _err(-32001, "node not found: %s (pass create:true to auto-create)" % node_path)
|
||||
|
||||
# Resolve the visual source.
|
||||
var source: Node = null
|
||||
if source_path != "":
|
||||
source = root.get_node_or_null(source_path)
|
||||
if source == null:
|
||||
return _err(-32001, "source not found: %s" % source_path)
|
||||
elif target != null:
|
||||
# Look among siblings for a Sprite2D / AnimatedSprite2D.
|
||||
var parent := target.get_parent()
|
||||
if parent:
|
||||
for sib in parent.get_children():
|
||||
if sib is Sprite2D or sib is AnimatedSprite2D:
|
||||
source = sib
|
||||
break
|
||||
if source == null:
|
||||
return _err(-32001, "no Sprite2D/AnimatedSprite2D source found — pass 'source'")
|
||||
|
||||
# Compute visual bounds (untransformed; CollisionShape2D inherits parent's transform).
|
||||
var bounds := _sprite_bounds(source)
|
||||
if bounds.size.x <= 0 or bounds.size.y <= 0:
|
||||
return _err(-32001, "source has no visible extents (%s)" % source.get_class())
|
||||
|
||||
# Create the CollisionShape2D if needed.
|
||||
if target == null and create:
|
||||
var inst := CollisionShape2D.new()
|
||||
inst.name = node_path.get_file() if "/" in node_path else node_path
|
||||
var parent := source.get_parent()
|
||||
parent.add_child(inst)
|
||||
inst.owner = root
|
||||
target = inst
|
||||
|
||||
if not target is CollisionShape2D:
|
||||
return _err(-32602, "target is not a CollisionShape2D: %s (%s)" % [node_path, target.get_class()])
|
||||
var cs: CollisionShape2D = target
|
||||
|
||||
# Build the requested shape sized to bounds.
|
||||
var shape_res: Shape2D
|
||||
match shape_type:
|
||||
"rectangle":
|
||||
var r := RectangleShape2D.new()
|
||||
r.size = Vector2(
|
||||
max(1.0, bounds.size.x - margin * 2.0),
|
||||
max(1.0, bounds.size.y - margin * 2.0)
|
||||
)
|
||||
shape_res = r
|
||||
"circle":
|
||||
var c := CircleShape2D.new()
|
||||
c.radius = max(1.0, min(bounds.size.x, bounds.size.y) / 2.0 - margin)
|
||||
shape_res = c
|
||||
"capsule":
|
||||
var cap := CapsuleShape2D.new()
|
||||
cap.radius = max(1.0, min(bounds.size.x, bounds.size.y) / 2.0 - margin)
|
||||
cap.height = max(cap.radius * 2.0 + 1.0, bounds.size.y - margin * 2.0)
|
||||
shape_res = cap
|
||||
_:
|
||||
return _err(-32602, "unknown shape '%s' (supported: rectangle, circle, capsule)" % shape_type)
|
||||
|
||||
cs.shape = shape_res
|
||||
# Center the CollisionShape2D on the sprite's center.
|
||||
cs.position = bounds.position + bounds.size / 2.0
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
|
||||
return _ok({
|
||||
"node_path": String(root.get_path_to(cs)),
|
||||
"shape": shape_type,
|
||||
"size": [bounds.size.x - margin * 2.0, bounds.size.y - margin * 2.0],
|
||||
"center": [cs.position.x, cs.position.y],
|
||||
"source": String(root.get_path_to(source)),
|
||||
})
|
||||
|
||||
|
||||
static func _sprite_bounds(source: Node) -> Rect2:
|
||||
if source is Sprite2D:
|
||||
var s: Sprite2D = source
|
||||
if s.texture == null:
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
var tex_size: Vector2 = s.texture.get_size()
|
||||
if s.region_enabled:
|
||||
tex_size = s.region_rect.size
|
||||
var size: Vector2 = tex_size * Vector2(abs(s.scale.x), abs(s.scale.y))
|
||||
# For centered sprites, bounds are at (-size/2, -size/2).
|
||||
var offset: Vector2 = -size / 2.0 if s.centered else Vector2.ZERO
|
||||
offset += s.offset
|
||||
return Rect2(offset, size)
|
||||
if source is AnimatedSprite2D:
|
||||
var a: AnimatedSprite2D = source
|
||||
if a.sprite_frames == null or a.animation == "":
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
var tex := a.sprite_frames.get_frame_texture(a.animation, a.frame)
|
||||
if tex == null:
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
var size: Vector2 = tex.get_size() * Vector2(abs(a.scale.x), abs(a.scale.y))
|
||||
var offset: Vector2 = -size / 2.0 if a.centered else Vector2.ZERO
|
||||
offset += a.offset
|
||||
return Rect2(offset, size)
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c26p52t0pcj6q
|
||||
@@ -0,0 +1,89 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# project.get_setting — read a value from project.godot. Returns {exists: false} if unset.
|
||||
# Params: {key: "application/config/name"}
|
||||
static func get_setting(params: Dictionary) -> Dictionary:
|
||||
var key: String = params.get("key", "")
|
||||
if key == "":
|
||||
return _err(-32602, "missing 'key'")
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _ok({"key": key, "exists": false})
|
||||
return _ok({"key": key, "exists": true, "value": ProjectSettings.get_setting(key)})
|
||||
|
||||
|
||||
# project.set_setting — write a value to project.godot and save.
|
||||
# DESTRUCTIVE: mutates project.godot. Prefer targeted tools (autoload.add, etc.) when available.
|
||||
# Params: {key, value}
|
||||
static func set_setting(params: Dictionary) -> Dictionary:
|
||||
var key: String = params.get("key", "")
|
||||
if key == "":
|
||||
return _err(-32602, "missing 'key'")
|
||||
if not params.has("value"):
|
||||
return _err(-32602, "missing 'value'")
|
||||
ProjectSettings.set_setting(key, params["value"])
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "ProjectSettings.save() failed: %d" % err)
|
||||
return _ok({"key": key, "value": params["value"]})
|
||||
|
||||
|
||||
# autoload.add — register an autoload (singleton by default).
|
||||
# Params: {name, path, singleton?: true}
|
||||
static func autoload_add(params: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = params.get("name", "")
|
||||
var path: String = params.get("path", "")
|
||||
var singleton: bool = params.get("singleton", true)
|
||||
if autoload_name == "" or path == "":
|
||||
return _err(-32602, "missing 'name' or 'path'")
|
||||
if not ResourceLoader.exists(path):
|
||||
return _err(-32001, "path does not exist: %s" % path)
|
||||
|
||||
var value := ("*" + path) if singleton else path
|
||||
ProjectSettings.set_setting("autoload/" + autoload_name, value)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
return _ok({"name": autoload_name, "path": path, "singleton": singleton})
|
||||
|
||||
|
||||
# autoload.remove — unregister an autoload.
|
||||
# Params: {name}
|
||||
static func autoload_remove(params: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = params.get("name", "")
|
||||
if autoload_name == "":
|
||||
return _err(-32602, "missing 'name'")
|
||||
var key := "autoload/" + autoload_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "autoload not found: %s" % autoload_name)
|
||||
ProjectSettings.clear(key)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
return _ok({"removed": autoload_name})
|
||||
|
||||
|
||||
# autoload.list — enumerate all registered autoloads.
|
||||
static func autoload_list(_params: Dictionary) -> Dictionary:
|
||||
var items: Array = []
|
||||
# ProjectSettings.get_property_list() contains our autoload entries under "autoload/*".
|
||||
for p in ProjectSettings.get_property_list():
|
||||
var name: String = p.name
|
||||
if not name.begins_with("autoload/"):
|
||||
continue
|
||||
var value: String = str(ProjectSettings.get_setting(name))
|
||||
var singleton := value.begins_with("*")
|
||||
items.append({
|
||||
"name": name.trim_prefix("autoload/"),
|
||||
"path": value.trim_prefix("*") if singleton else value,
|
||||
"singleton": singleton,
|
||||
})
|
||||
return _ok({"autoloads": items})
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsfbwkk5nas36
|
||||
@@ -0,0 +1,371 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# refs.validate_project — project-wide sweep. No params.
|
||||
# Returns {checked: int, issues: [{path, kind, ...}]}.
|
||||
# Kinds: script_parse_error, scene_load_failed, scene_instantiate_failed,
|
||||
# resource_load_failed, missing_ext_resource, missing_uid,
|
||||
# signal_method_missing, signal_target_missing.
|
||||
static func validate_project(_params: Dictionary) -> Dictionary:
|
||||
var issues: Array = []
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres"], files)
|
||||
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue # don't validate ourselves
|
||||
if path.ends_with(".gd"):
|
||||
_check_script(path, issues)
|
||||
elif path.ends_with(".tscn"):
|
||||
_check_scene(path, issues)
|
||||
elif path.ends_with(".tres"):
|
||||
_check_resource(path, issues)
|
||||
|
||||
return {"data": {"checked": files.size(), "issues": issues}}
|
||||
|
||||
|
||||
static func _walk(dir_path: String, exts: Array, out: Array) -> void:
|
||||
var d := DirAccess.open(dir_path)
|
||||
if d == null:
|
||||
return
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var name := d.get_next()
|
||||
if name == "":
|
||||
break
|
||||
if name.begins_with("."):
|
||||
continue
|
||||
var full := dir_path.path_join(name)
|
||||
if d.current_is_dir():
|
||||
_walk(full, exts, out)
|
||||
else:
|
||||
for e in exts:
|
||||
if name.ends_with("." + e):
|
||||
out.append(full)
|
||||
break
|
||||
d.list_dir_end()
|
||||
|
||||
|
||||
static func _check_script(path: String, issues: Array) -> void:
|
||||
# Go through the normal loader so class_name / preload chains resolve against
|
||||
# the real global registry. A detached GDScript.new() + reload() would false-positive
|
||||
# on any script with class_name (duplicate global class registration).
|
||||
var script := ResourceLoader.load(path, "Script")
|
||||
if script == null:
|
||||
issues.append({"path": path, "kind": "script_load_failed"})
|
||||
|
||||
|
||||
static func _check_scene(path: String, issues: Array) -> void:
|
||||
_check_ext_resources_text(path, issues)
|
||||
|
||||
var packed := ResourceLoader.load(path, "PackedScene") as PackedScene
|
||||
if packed == null:
|
||||
issues.append({"path": path, "kind": "scene_load_failed"})
|
||||
return
|
||||
var root := packed.instantiate(PackedScene.GEN_EDIT_STATE_DISABLED)
|
||||
if root == null:
|
||||
issues.append({"path": path, "kind": "scene_instantiate_failed"})
|
||||
return
|
||||
_walk_scene_signals(root, root, path, issues)
|
||||
root.free()
|
||||
|
||||
|
||||
static func _walk_scene_signals(node: Node, scene_root: Node, scene_path: String, issues: Array) -> void:
|
||||
for sig in node.get_signal_list():
|
||||
var sig_name: String = sig.name
|
||||
for c in node.get_signal_connection_list(sig_name):
|
||||
var flags: int = c.flags
|
||||
if not (flags & CONNECT_PERSIST):
|
||||
continue # ignore runtime-only connections
|
||||
var cb: Callable = c.callable
|
||||
var target: Object = cb.get_object()
|
||||
var method: String = cb.get_method()
|
||||
if target == null:
|
||||
issues.append({
|
||||
"path": scene_path,
|
||||
"kind": "signal_target_missing",
|
||||
"node": String(scene_root.get_path_to(node)),
|
||||
"signal": sig_name,
|
||||
})
|
||||
elif not target.has_method(method):
|
||||
issues.append({
|
||||
"path": scene_path,
|
||||
"kind": "signal_method_missing",
|
||||
"node": String(scene_root.get_path_to(node)),
|
||||
"signal": sig_name,
|
||||
"method": method,
|
||||
})
|
||||
for child in node.get_children():
|
||||
if child.owner == scene_root or child == scene_root:
|
||||
_walk_scene_signals(child, scene_root, scene_path, issues)
|
||||
|
||||
|
||||
static func _check_resource(path: String, issues: Array) -> void:
|
||||
var res := ResourceLoader.load(path)
|
||||
if res == null:
|
||||
issues.append({"path": path, "kind": "resource_load_failed"})
|
||||
|
||||
|
||||
# refs.find_usages — find every file that references a given resource.
|
||||
# Target may be either a path ("res://player.gd") or a uid ("uid://abc123").
|
||||
# Searches for both forms so you catch uid-indirected references too.
|
||||
# Params: {target: "res://..." | "uid://..."}
|
||||
static func find_usages(params: Dictionary) -> Dictionary:
|
||||
var target: String = params.get("target", "")
|
||||
if target == "":
|
||||
return _err(-32602, "missing 'target'")
|
||||
|
||||
var terms: Array = [target]
|
||||
if target.begins_with("uid://"):
|
||||
var id := ResourceUID.text_to_id(target)
|
||||
if id != -1 and ResourceUID.has_id(id):
|
||||
var p := ResourceUID.get_id_path(id)
|
||||
if p != "" and p != target:
|
||||
terms.append(p)
|
||||
else:
|
||||
var id := ResourceLoader.get_resource_uid(target)
|
||||
if id != -1:
|
||||
terms.append(ResourceUID.id_to_text(id))
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres", "cfg", "gdshader", "gdshaderinc", "cs", "godot"], files)
|
||||
|
||||
var matches: Array = []
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
if path == target:
|
||||
continue # skip the file itself
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var line_num := 0
|
||||
while not f.eof_reached():
|
||||
line_num += 1
|
||||
var line := f.get_line()
|
||||
for term in terms:
|
||||
if term in line:
|
||||
matches.append({
|
||||
"path": path,
|
||||
"line": line_num,
|
||||
"matched": term,
|
||||
"text": line.strip_edges(),
|
||||
})
|
||||
break
|
||||
|
||||
return _ok({"target": target, "terms_searched": terms, "matches": matches})
|
||||
|
||||
|
||||
# refs.rename — move a file and rewrite all references to it.
|
||||
# Path-form references are text-replaced; uid-form references keep working because
|
||||
# the .uid sidecar moves with the file (preserving the UID → new path mapping).
|
||||
# Params: {from, to, overwrite?: false, dry_run?: false}
|
||||
static func rename(params: Dictionary) -> Dictionary:
|
||||
var from_path: String = params.get("from", "")
|
||||
var to_path: String = params.get("to", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
var dry_run: bool = params.get("dry_run", false)
|
||||
|
||||
if from_path == "" or to_path == "":
|
||||
return _err(-32602, "missing 'from' or 'to'")
|
||||
if not (from_path.begins_with("res://") and to_path.begins_with("res://")):
|
||||
return _err(-32602, "paths must be 'res://...'")
|
||||
if from_path == to_path:
|
||||
return _err(-32602, "'from' and 'to' are the same")
|
||||
if not FileAccess.file_exists(from_path):
|
||||
return _err(-32001, "source not found: %s" % from_path)
|
||||
if FileAccess.file_exists(to_path) and not overwrite:
|
||||
return _err(-32602, "destination exists (pass overwrite:true): %s" % to_path)
|
||||
|
||||
# Collect path-form usages. UID-form references are preserved automatically when
|
||||
# the .uid sidecar moves with the file, so we don't need to rewrite them.
|
||||
var usage := find_usages({"target": from_path})
|
||||
if usage.has("error"):
|
||||
return usage
|
||||
var edits: Dictionary = {}
|
||||
for m in usage.data.matches:
|
||||
if m.matched != from_path:
|
||||
continue # uid-form match — no rewrite needed
|
||||
if not edits.has(m.path):
|
||||
edits[m.path] = true
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
"would_move": {"from": from_path, "to": to_path},
|
||||
"would_update": edits.keys(),
|
||||
})
|
||||
|
||||
var dir_path := to_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var move_err := DirAccess.rename_absolute(from_path, to_path)
|
||||
if move_err != OK:
|
||||
return _err(-32001, "move failed (%d)" % move_err)
|
||||
|
||||
# Move .uid sidecar so UIDs continue resolving to the new path. Best-effort —
|
||||
# sidecar failures don't block the main rename that already succeeded.
|
||||
var from_uid := from_path + ".uid"
|
||||
if FileAccess.file_exists(from_uid):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.rename_absolute(from_uid, to_path + ".uid")
|
||||
# Move .import sidecar for imported assets (textures, audio, etc.).
|
||||
var from_import := from_path + ".import"
|
||||
if FileAccess.file_exists(from_import):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.rename_absolute(from_import, to_path + ".import")
|
||||
|
||||
var files_updated: Array = []
|
||||
for ref_path in edits.keys():
|
||||
var rf := FileAccess.open(ref_path, FileAccess.READ)
|
||||
if rf == null:
|
||||
continue
|
||||
var content := rf.get_as_text()
|
||||
rf.close()
|
||||
var new_content := content.replace(from_path, to_path)
|
||||
if new_content == content:
|
||||
continue
|
||||
var wf := FileAccess.open(ref_path, FileAccess.WRITE)
|
||||
if wf == null:
|
||||
continue
|
||||
wf.store_string(new_content)
|
||||
wf.close()
|
||||
files_updated.append(ref_path)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return _ok({
|
||||
"moved": {"from": from_path, "to": to_path},
|
||||
"files_updated": files_updated,
|
||||
})
|
||||
|
||||
|
||||
# refs.rename_class — rename `class_name X` to `class_name Y` and rewrite every
|
||||
# word-boundary reference to X across .gd / .tscn / .tres files. Best-effort:
|
||||
# won't rewrite an X that happens to be a local variable name (word boundary
|
||||
# alone isn't semantic analysis). Use dry_run first on anything non-trivial.
|
||||
# Params: {from, to, dry_run?: false}
|
||||
static func rename_class(params: Dictionary) -> Dictionary:
|
||||
var from_name: String = params.get("from", "")
|
||||
var to_name: String = params.get("to", "")
|
||||
var dry_run: bool = params.get("dry_run", false)
|
||||
|
||||
if from_name == "" or to_name == "":
|
||||
return _err(-32602, "missing 'from' or 'to'")
|
||||
if not _is_valid_identifier(from_name):
|
||||
return _err(-32602, "'from' is not a valid identifier: %s" % from_name)
|
||||
if not _is_valid_identifier(to_name):
|
||||
return _err(-32602, "'to' is not a valid identifier: %s" % to_name)
|
||||
if from_name == to_name:
|
||||
return _err(-32602, "'from' and 'to' are identical")
|
||||
|
||||
var defining_script := _find_class_definition(from_name)
|
||||
if defining_script == "":
|
||||
return _err(-32001, "no script found declaring class_name %s" % from_name)
|
||||
if _find_class_definition(to_name) != "":
|
||||
return _err(-32602, "class_name %s already exists" % to_name)
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres"], files)
|
||||
|
||||
var regex := RegEx.new()
|
||||
regex.compile("\\b%s\\b" % from_name)
|
||||
|
||||
var edits: Array = []
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
if regex.search(text) != null:
|
||||
edits.append(path)
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
"from": from_name,
|
||||
"to": to_name,
|
||||
"defining_script": defining_script,
|
||||
"would_update": edits,
|
||||
})
|
||||
|
||||
var updated: Array = []
|
||||
for path in edits:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
var new_text := regex.sub(text, to_name, true)
|
||||
if new_text == text:
|
||||
continue
|
||||
var wf := FileAccess.open(path, FileAccess.WRITE)
|
||||
if wf == null:
|
||||
continue
|
||||
wf.store_string(new_text)
|
||||
wf.close()
|
||||
updated.append(path)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return _ok({
|
||||
"from": from_name,
|
||||
"to": to_name,
|
||||
"defining_script": defining_script,
|
||||
"updated": updated,
|
||||
})
|
||||
|
||||
|
||||
static func _is_valid_identifier(s: String) -> bool:
|
||||
var re := RegEx.new()
|
||||
re.compile("^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
return re.search(s) != null
|
||||
|
||||
|
||||
static func _find_class_definition(class_name_val: String) -> String:
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd"], files)
|
||||
var re := RegEx.new()
|
||||
re.compile("^\\s*class_name\\s+%s\\b" % class_name_val)
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
for line in text.split("\n"):
|
||||
if re.search(line) != null:
|
||||
return path
|
||||
return ""
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
|
||||
|
||||
# Parse the raw .tscn/.tres text for [ext_resource ... path="..."] and verify each target resolves.
|
||||
# Catches references to files that were deleted or moved without updating the scene.
|
||||
static func _check_ext_resources_text(path: String, issues: Array) -> void:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return
|
||||
var text := f.get_as_text()
|
||||
var regex := RegEx.new()
|
||||
regex.compile("\\[ext_resource[^\\]]*path=\"([^\"]+)\"")
|
||||
for m in regex.search_all(text):
|
||||
var ref: String = m.get_string(1)
|
||||
if ref.begins_with("uid://"):
|
||||
var id := ResourceUID.text_to_id(ref)
|
||||
if id == -1 or not ResourceUID.has_id(id):
|
||||
issues.append({"path": path, "kind": "missing_uid", "uid": ref})
|
||||
continue
|
||||
if not ResourceLoader.exists(ref):
|
||||
issues.append({"path": path, "kind": "missing_ext_resource", "target": ref})
|
||||
@@ -0,0 +1 @@
|
||||
uid://vlnvjolougnv
|
||||
@@ -0,0 +1,175 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
|
||||
|
||||
# resource.create — create a new .tres file.
|
||||
# Params: {path, type, script?, properties?, overwrite?: false}
|
||||
# 'type' must be a built-in Resource subclass (StyleBoxFlat, Theme, Curve, etc.).
|
||||
# For custom Resource subclasses written in GDScript, pass 'script' pointing at the
|
||||
# class's .gd file instead of 'type'.
|
||||
static func create(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var type_name: String = params.get("type", "")
|
||||
var script_path: String = params.get("script", "")
|
||||
var properties: Dictionary = params.get("properties", {})
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
|
||||
if path == "" or not path.begins_with("res://") or not path.ends_with(".tres"):
|
||||
return _err(-32602, "'path' must be 'res://...' ending in .tres")
|
||||
if FileAccess.file_exists(path) and not overwrite:
|
||||
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
|
||||
|
||||
var res: Resource
|
||||
if script_path != "":
|
||||
var s := ResourceLoader.load(script_path, "Script") as Script
|
||||
if s == null:
|
||||
return _err(-32001, "failed to load script: %s" % script_path)
|
||||
var inst = s.new()
|
||||
if not (inst is Resource):
|
||||
return _err(-32602, "script does not produce a Resource")
|
||||
res = inst
|
||||
elif type_name != "":
|
||||
if not ClassDB.class_exists(type_name):
|
||||
return _err(-32602, "unknown class: %s (use 'script' for custom Resource types)" % type_name)
|
||||
if not ClassDB.is_parent_class(type_name, "Resource"):
|
||||
return _err(-32602, "not a Resource subclass: %s" % type_name)
|
||||
if not ClassDB.can_instantiate(type_name):
|
||||
return _err(-32602, "class is not instantiable: %s" % type_name)
|
||||
res = ClassDB.instantiate(type_name) as Resource
|
||||
else:
|
||||
return _err(-32602, "provide either 'type' (built-in Resource class) or 'script' (path to custom Resource .gd)")
|
||||
|
||||
var apply_res := _apply_properties(res, properties)
|
||||
if apply_res.has("error"):
|
||||
return apply_res
|
||||
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var save_err := ResourceSaver.save(res, path)
|
||||
if save_err != OK:
|
||||
return _err(-32001, "ResourceSaver.save failed: %d" % save_err)
|
||||
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
return _ok({"path": path, "class": res.get_class()})
|
||||
|
||||
|
||||
# resource.set_property — load a .tres, set one property, save it back.
|
||||
# Params: {path, property, value} — same coercion as scene.set_property.
|
||||
static func set_property(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var property_name: String = params.get("property", "")
|
||||
if path == "" or property_name == "":
|
||||
return _err(-32602, "missing 'path' or 'property'")
|
||||
|
||||
var res := ResourceLoader.load(path)
|
||||
if res == null:
|
||||
return _err(-32001, "failed to load: %s" % path)
|
||||
|
||||
var prop_info: Dictionary = {}
|
||||
for p in res.get_property_list():
|
||||
if p.name == property_name:
|
||||
prop_info = p
|
||||
break
|
||||
if prop_info.is_empty():
|
||||
return _err(-32602, "property not found on %s: %s" % [res.get_class(), property_name])
|
||||
if int(prop_info.get("usage", 0)) & PROPERTY_USAGE_READ_ONLY:
|
||||
return _err(-32602, "property '%s' on %s is read-only" % [property_name, res.get_class()])
|
||||
|
||||
var coerced = Coerce.coerce(params.get("value"), prop_info.type)
|
||||
if coerced is Dictionary and coerced.has("_error"):
|
||||
return _err(-32602, coerced._error)
|
||||
|
||||
res.set(property_name, coerced)
|
||||
var stored = res.get(property_name)
|
||||
if coerced != null and stored == null and prop_info.type == TYPE_OBJECT:
|
||||
return _err(-32001,
|
||||
"property '%s' on %s was not accepted (assignment silently dropped — target expects a %s; passed value type didn't match)" %
|
||||
[property_name, res.get_class(), prop_info.get("hint_string", "Resource")])
|
||||
var save_err := ResourceSaver.save(res, path)
|
||||
if save_err != OK:
|
||||
return _err(-32001, "save failed: %d" % save_err)
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
return _ok({"path": path, "property": property_name, "value": Coerce.to_json(stored)})
|
||||
|
||||
|
||||
# resource.call_method — load a .tres, invoke a method, save, return the method's result.
|
||||
# Useful for helper methods that aren't properties — e.g. StyleBoxFlat.set_border_width_all(4)
|
||||
# or set_corner_radius_all(14). Args are coerced against the method's declared types.
|
||||
# Params: {path, method, args?: [], save?: true}
|
||||
static func call_method(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var method_name: String = params.get("method", "")
|
||||
var args: Array = params.get("args", [])
|
||||
var should_save: bool = params.get("save", true)
|
||||
if path == "" or method_name == "":
|
||||
return _err(-32602, "missing 'path' or 'method'")
|
||||
|
||||
var res := ResourceLoader.load(path)
|
||||
if res == null:
|
||||
return _err(-32001, "failed to load: %s" % path)
|
||||
if not res.has_method(method_name):
|
||||
return _err(-32602, "method not found: %s.%s" % [res.get_class(), method_name])
|
||||
|
||||
var coerced_args = _coerce_args(res, method_name, args)
|
||||
if coerced_args is Dictionary:
|
||||
return coerced_args # error passthrough (same reason as scene.call_method: no type annotation)
|
||||
var result = res.callv(method_name, coerced_args)
|
||||
|
||||
if should_save:
|
||||
var save_err := ResourceSaver.save(res, path)
|
||||
if save_err != OK:
|
||||
return _err(-32001, "save failed: %d" % save_err)
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
|
||||
return _ok({
|
||||
"path": path,
|
||||
"method": method_name,
|
||||
"return": Coerce.to_json(result),
|
||||
"saved": should_save,
|
||||
})
|
||||
|
||||
|
||||
static func _coerce_args(obj: Object, method_name: String, args: Array):
|
||||
var method_info: Dictionary = {}
|
||||
for m in obj.get_method_list():
|
||||
if m.name == method_name:
|
||||
method_info = m
|
||||
break
|
||||
var arg_types: Array = method_info.get("args", [])
|
||||
var out: Array = []
|
||||
for i in args.size():
|
||||
if i < arg_types.size():
|
||||
var coerced = Coerce.coerce(args[i], arg_types[i].type)
|
||||
if coerced is Dictionary and coerced.has("_error"):
|
||||
return _err(-32602, "arg %d: %s" % [i, coerced._error])
|
||||
out.append(coerced)
|
||||
else:
|
||||
out.append(args[i])
|
||||
return out
|
||||
|
||||
|
||||
static func _apply_properties(res: Resource, props: Dictionary) -> Dictionary:
|
||||
var by_name: Dictionary = {}
|
||||
for p in res.get_property_list():
|
||||
by_name[p.name] = p
|
||||
for key in props:
|
||||
if not by_name.has(key):
|
||||
return _err(-32602, "property not found on %s: %s" % [res.get_class(), key])
|
||||
var coerced = Coerce.coerce(props[key], by_name[key].type)
|
||||
if coerced is Dictionary and coerced.has("_error"):
|
||||
return _err(-32602, "property %s: %s" % [key, coerced._error])
|
||||
res.set(key, coerced)
|
||||
return {}
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cw3smf38qys7f
|
||||
@@ -0,0 +1,239 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# run.scene_headless — run a scene in a child process and capture structured results.
|
||||
#
|
||||
# MODES
|
||||
# BARE (no input_script / screenshot / screenshots / state_dump):
|
||||
# Runs the target directly with --headless. Fast, no window. Right for
|
||||
# "does _ready not crash" checks.
|
||||
#
|
||||
# DRIVEN (anything beyond "path" and "quit_after_seconds"):
|
||||
# Runs the target under a shipped wrapper driver (addons/agent_tools/headless/
|
||||
# driver.tscn) that reads a per-call config, injects scripted input per frame,
|
||||
# captures one or more screenshots, and dumps final scene state on quit.
|
||||
#
|
||||
# SCREENSHOT NOTE
|
||||
# Godot 4.6's --headless forces a Dummy renderer that makes captures impossible.
|
||||
# When any screenshot is requested the subprocess runs WITH a visible window
|
||||
# positioned at -9999,-9999 (offscreen on multi-monitor setups) and the real
|
||||
# Vulkan/OpenGL renderer. Brief window flash is unavoidable.
|
||||
#
|
||||
# Params:
|
||||
# path: required, "res://scene.tscn"
|
||||
# quit_after_seconds: default 2.0
|
||||
# extra_args: additional CLI args passed to the child godot process
|
||||
# input_script: [{frame, type, ...}, ...] see event shapes below
|
||||
# screenshot: single output PNG path (shorthand for one capture at quit_frame)
|
||||
# screenshots: [{frame, path}, ...] multiple captures at specific frames
|
||||
# resolution: "WxH", default "320x240". Only applies when screenshots requested.
|
||||
# state_dump: bool, default false. When true, driver writes a JSON snapshot
|
||||
# of the final scene tree (name/class/node_path/script/common
|
||||
# properties) that the tool returns as result.final_state.
|
||||
# seed: int, optional. Seeds the global RNG before instancing the
|
||||
# target so runs are reproducible.
|
||||
#
|
||||
# Event types for input_script:
|
||||
# {frame, type: "action_tap", action}
|
||||
# {frame, type: "action_press", action, strength?}
|
||||
# {frame, type: "action_release", action}
|
||||
# {frame, type: "key", keycode, pressed?: true}
|
||||
# {frame, type: "mouse_click", position: [x, y], button?: 1}
|
||||
# {frame, type: "mouse_motion", position: [x, y]}
|
||||
#
|
||||
# Return includes: exit_code, output (stdout/stderr), command, errors, warnings,
|
||||
# screenshots (array of {frame, path, captured: bool}), final_state (if state_dump),
|
||||
# mode, quit_after_frames.
|
||||
|
||||
const STATE_DUMP_PATH := "res://.godot/agent_tools/headless/state_dump.json"
|
||||
|
||||
|
||||
static func scene_headless(params: Dictionary) -> Dictionary:
|
||||
var scene_path: String = params.get("path", "")
|
||||
var quit_after_seconds: float = float(params.get("quit_after_seconds", 2.0))
|
||||
var extra_args: Array = params.get("extra_args", [])
|
||||
var input_script = params.get("input_script", null)
|
||||
var resolution: String = params.get("resolution", "320x240")
|
||||
var state_dump: bool = params.get("state_dump", false)
|
||||
var rng_seed = params.get("seed", null)
|
||||
|
||||
# Normalize both screenshot forms into a single list.
|
||||
var screenshots: Array = []
|
||||
var single_shot: String = params.get("screenshot", "")
|
||||
if single_shot != "":
|
||||
screenshots.append({"frame": int(round(quit_after_seconds * 60.0)), "path": single_shot})
|
||||
var multi: Array = params.get("screenshots", [])
|
||||
for s in multi:
|
||||
if typeof(s) != TYPE_DICTIONARY or not s.has("frame") or not s.has("path"):
|
||||
return _err(-32602, "each 'screenshots' entry needs {frame, path}")
|
||||
screenshots.append({"frame": int(s.frame), "path": str(s.path)})
|
||||
|
||||
if scene_path == "":
|
||||
return _err(-32602, "missing 'path'")
|
||||
if not ResourceLoader.exists(scene_path, "PackedScene"):
|
||||
return _err(-32001, "scene not found: %s" % scene_path)
|
||||
|
||||
var frames: int = max(1, int(round(quit_after_seconds * 60.0)))
|
||||
var use_driver := input_script != null or not screenshots.is_empty() or state_dump or rng_seed != null
|
||||
var needs_window := not screenshots.is_empty()
|
||||
|
||||
var run_scene_path := scene_path
|
||||
if use_driver:
|
||||
# Wipe any stale state dump from prior run so "file exists" is a reliable signal.
|
||||
if FileAccess.file_exists(STATE_DUMP_PATH):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(STATE_DUMP_PATH)
|
||||
# Strict-typed arg: can't pass a Variant-valued ternary directly into an
|
||||
# Array param (parse error). Normalize first.
|
||||
var script_list: Array = []
|
||||
if input_script is Array:
|
||||
script_list = input_script
|
||||
var prep := _prepare_driver_config(scene_path, script_list, frames, screenshots, state_dump, rng_seed)
|
||||
if prep.has("_error"):
|
||||
return _err(-32001, prep._error)
|
||||
run_scene_path = "res://addons/agent_tools/headless/driver.tscn"
|
||||
|
||||
var exe := OS.get_executable_path()
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
var hard_quit_frames: int = frames + 30
|
||||
|
||||
var args: Array = ["--path", project_dir]
|
||||
if needs_window:
|
||||
# Real renderer required for screenshots; run windowed but offscreen.
|
||||
args.append("--windowed")
|
||||
args.append("--resolution")
|
||||
args.append(resolution)
|
||||
args.append("--position")
|
||||
args.append("-9999,-9999")
|
||||
else:
|
||||
args.append("--headless")
|
||||
args.append("--quit-after")
|
||||
args.append(str(hard_quit_frames))
|
||||
for a in extra_args:
|
||||
args.append(str(a))
|
||||
args.append(run_scene_path)
|
||||
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(exe, args, output, true)
|
||||
# str() always returns a String; avoids Variant-via-ternary parse issue.
|
||||
var stdout: String = ""
|
||||
if output.size() > 0:
|
||||
stdout = str(output[0])
|
||||
|
||||
var diagnostics := _extract_diagnostics(stdout)
|
||||
|
||||
var result: Dictionary = {
|
||||
"exit_code": exit_code,
|
||||
"output": stdout,
|
||||
"command": "%s %s" % [exe, " ".join(args)],
|
||||
"quit_after_frames": frames,
|
||||
"mode": "driven" if use_driver else "bare",
|
||||
"errors": diagnostics.errors,
|
||||
"warnings": diagnostics.warnings,
|
||||
}
|
||||
if use_driver:
|
||||
result["driver_scene"] = run_scene_path
|
||||
if not screenshots.is_empty():
|
||||
var shot_results: Array = []
|
||||
for s in screenshots:
|
||||
shot_results.append({
|
||||
"frame": s.frame,
|
||||
"path": s.path,
|
||||
"captured": FileAccess.file_exists(s.path),
|
||||
})
|
||||
result["screenshots"] = shot_results
|
||||
if state_dump:
|
||||
# = not := — _load_state_dump has no return annotation so := can't infer.
|
||||
var state = _load_state_dump()
|
||||
if state != null:
|
||||
result["final_state"] = state
|
||||
return _ok(result)
|
||||
|
||||
|
||||
static func _prepare_driver_config(target: String, input_script: Array, quit_frame: int, screenshots: Array, state_dump: bool, rng_seed) -> Dictionary:
|
||||
var cfg_dir := "res://.godot/agent_tools/headless"
|
||||
if not DirAccess.dir_exists_absolute(cfg_dir):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(cfg_dir)
|
||||
if derr != OK:
|
||||
return {"_error": "mkdir failed for %s (%d)" % [cfg_dir, derr]}
|
||||
|
||||
var config := {
|
||||
"target_scene": target,
|
||||
"input_script": input_script,
|
||||
"quit_frame": quit_frame,
|
||||
"screenshots": screenshots,
|
||||
"state_dump": state_dump,
|
||||
"state_dump_path": STATE_DUMP_PATH,
|
||||
}
|
||||
if rng_seed != null:
|
||||
config["seed"] = int(rng_seed)
|
||||
|
||||
var cfg_path := cfg_dir + "/config.json"
|
||||
var f := FileAccess.open(cfg_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return {"_error": "failed to write driver config at %s" % cfg_path}
|
||||
f.store_string(JSON.stringify(config, " "))
|
||||
f.close()
|
||||
return {"config_path": cfg_path}
|
||||
|
||||
|
||||
# Parse Godot's stdout for ERROR:/WARNING:/SCRIPT ERROR:/USER ERROR:/USER WARNING: lines.
|
||||
# Returns {errors: [{category, message, context}], warnings: [{category, message}]}.
|
||||
# Context is the next line if it starts with whitespace (typical "at: file:line" trailer).
|
||||
static func _extract_diagnostics(stdout: String) -> Dictionary:
|
||||
var errors: Array = []
|
||||
var warnings: Array = []
|
||||
var lines: PackedStringArray = stdout.split("\n")
|
||||
var i := 0
|
||||
while i < lines.size():
|
||||
var line: String = lines[i].trim_suffix("\r")
|
||||
var category := ""
|
||||
var is_error := false
|
||||
var is_warning := false
|
||||
for prefix in ["ERROR:", "USER ERROR:", "SCRIPT ERROR:"]:
|
||||
if line.begins_with(prefix):
|
||||
category = prefix.trim_suffix(":")
|
||||
is_error = true
|
||||
break
|
||||
if not is_error:
|
||||
for prefix in ["WARNING:", "USER WARNING:"]:
|
||||
if line.begins_with(prefix):
|
||||
category = prefix.trim_suffix(":")
|
||||
is_warning = true
|
||||
break
|
||||
if is_error or is_warning:
|
||||
var msg: String = line.substr(category.length() + 1).strip_edges()
|
||||
var context := ""
|
||||
if i + 1 < lines.size():
|
||||
var next := lines[i + 1].trim_suffix("\r")
|
||||
if next.begins_with(" ") or next.begins_with("\t"):
|
||||
context = next.strip_edges()
|
||||
i += 1
|
||||
if is_error:
|
||||
errors.append({"category": category, "message": msg, "context": context})
|
||||
else:
|
||||
warnings.append({"category": category, "message": msg, "context": context})
|
||||
i += 1
|
||||
return {"errors": errors, "warnings": warnings}
|
||||
|
||||
|
||||
static func _load_state_dump():
|
||||
if not FileAccess.file_exists(STATE_DUMP_PATH):
|
||||
return null
|
||||
var f := FileAccess.open(STATE_DUMP_PATH, FileAccess.READ)
|
||||
if f == null:
|
||||
return null
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
var parsed = JSON.parse_string(text)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return null
|
||||
return parsed
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://7r486ud37chv
|
||||
@@ -0,0 +1,734 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
|
||||
|
||||
# scene.inspect — read-only. Accepts {"path": "res://..."} or omits path to use the currently-edited scene.
|
||||
# Returns {root: <NodeDict>, path: "<scene_file_path>"} where NodeDict = {name, class, node_path, script, children}.
|
||||
static func inspect(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var root: Node
|
||||
var owns_root := false
|
||||
var scene_file_path: String
|
||||
|
||||
if path == "":
|
||||
root = EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open and no 'path' provided")
|
||||
scene_file_path = root.scene_file_path
|
||||
else:
|
||||
var packed := ResourceLoader.load(path, "PackedScene") as PackedScene
|
||||
if packed == null:
|
||||
return _err(-32001, "failed to load scene: %s" % path)
|
||||
root = packed.instantiate(PackedScene.GEN_EDIT_STATE_DISABLED)
|
||||
if root == null:
|
||||
return _err(-32001, "instantiate failed: %s" % path)
|
||||
owns_root = true
|
||||
scene_file_path = path
|
||||
|
||||
var tree := _node_to_dict(root, root)
|
||||
if owns_root:
|
||||
root.free()
|
||||
return _ok({"path": scene_file_path, "root": tree})
|
||||
|
||||
|
||||
static func _node_to_dict(node: Node, scene_root: Node) -> Dictionary:
|
||||
var d := {
|
||||
"name": String(node.name),
|
||||
"class": node.get_class(),
|
||||
"node_path": "." if node == scene_root else String(scene_root.get_path_to(node)),
|
||||
"script": "",
|
||||
"children": [],
|
||||
}
|
||||
var scr := node.get_script() as Script
|
||||
if scr:
|
||||
d.script = scr.resource_path
|
||||
for child in node.get_children():
|
||||
# Only include nodes belonging to this scene (skip runtime-added children of instanced sub-scenes).
|
||||
if child.owner == scene_root or child == scene_root:
|
||||
d.children.append(_node_to_dict(child, scene_root))
|
||||
return d
|
||||
|
||||
|
||||
# scene.add_node — operates on the currently-edited scene.
|
||||
# Params: {type: "Node2D", name?: "Foo", parent_path?: "."}
|
||||
static func add_node(params: Dictionary) -> Dictionary:
|
||||
var node_type: String = params.get("type", "")
|
||||
var node_name: String = params.get("name", "")
|
||||
var parent_path: String = params.get("parent_path", ".")
|
||||
|
||||
if node_type == "":
|
||||
return _err(-32602, "missing 'type'")
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return _err(-32602, "unknown class: %s" % node_type)
|
||||
if not ClassDB.is_parent_class(node_type, "Node"):
|
||||
return _err(-32602, "type must derive from Node: %s" % node_type)
|
||||
if not ClassDB.can_instantiate(node_type):
|
||||
return _err(-32602, "class is not instantiable: %s" % node_type)
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
|
||||
var parent: Node = root if parent_path == "." or parent_path == "" else root.get_node_or_null(parent_path)
|
||||
if parent == null:
|
||||
return _err(-32001, "parent not found: %s" % parent_path)
|
||||
|
||||
var inst := ClassDB.instantiate(node_type) as Node
|
||||
if inst == null:
|
||||
return _err(-32001, "failed to instantiate %s" % node_type)
|
||||
|
||||
parent.add_child(inst)
|
||||
if node_name != "":
|
||||
inst.name = node_name
|
||||
# Owner MUST be set to the scene root, otherwise the node won't be saved.
|
||||
inst.owner = root
|
||||
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"node_path": String(root.get_path_to(inst)),
|
||||
"name": String(inst.name),
|
||||
"class": inst.get_class(),
|
||||
})
|
||||
|
||||
|
||||
# scene.set_property — operates on the currently-edited scene.
|
||||
# Params: {node_path, property, value} — value is coerced via Coerce.coerce based on
|
||||
# the target property's type. See _coerce.gd for the full supported-type list.
|
||||
static func set_property(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var property_name: String = params.get("property", "")
|
||||
if node_path == "" or property_name == "":
|
||||
return _err(-32602, "missing 'node_path' or 'property'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
|
||||
var prop_info: Dictionary = {}
|
||||
for p in node.get_property_list():
|
||||
if p.name == property_name:
|
||||
prop_info = p
|
||||
break
|
||||
if prop_info.is_empty():
|
||||
return _err(-32602, "property not found on %s: %s" % [node.get_class(), property_name])
|
||||
# Reject read-only properties upfront — Godot would silently ignore the set().
|
||||
if int(prop_info.get("usage", 0)) & PROPERTY_USAGE_READ_ONLY:
|
||||
return _err(-32602, "property '%s' on %s is read-only" % [property_name, node.get_class()])
|
||||
|
||||
var assigned := _assign_property(node, property_name, prop_info, params.get("value"))
|
||||
if assigned.has("error"):
|
||||
return _err(int(assigned.get("code", -32001)), assigned.error)
|
||||
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
|
||||
return _ok({
|
||||
"node_path": node_path,
|
||||
"property": property_name,
|
||||
"value": Coerce.to_json(assigned.get("stored")),
|
||||
})
|
||||
|
||||
|
||||
# Assign a JSON-native value into node[property_name], handling three failure
|
||||
# modes the bare `node.set()` path gets wrong:
|
||||
#
|
||||
# 1. Node-typed @exports (`@export var foo: Node2D`) — TYPE_OBJECT with a
|
||||
# PROPERTY_HINT_NODE_TYPE hint. A path string must be resolved to the actual
|
||||
# Node so PackedScene.pack() emits the `node_paths=PackedStringArray(...)`
|
||||
# metadata and serializes it (the inspector drag-drop equivalent). A bare
|
||||
# string assignment is silently dropped at save time.
|
||||
# 2. Setter-routed properties (notably PROPERTY_USAGE_NO_EDITOR ones like
|
||||
# `unique_name_in_owner`) — `node.set()` doesn't reliably dispatch to the
|
||||
# registered setter. If the readback doesn't match, route through the
|
||||
# explicit `set_<name>` method.
|
||||
# 3. Silently-dropped assignments — Godot accepts the set() call but drops the
|
||||
# value (type mismatch on a Resource slot, or a primitive that didn't take).
|
||||
# Surface an error instead of echoing a misleading value.
|
||||
#
|
||||
# Returns {"stored": <value>} on success, or {"error": msg, "code": int} on failure.
|
||||
static func _assign_property(node: Node, property_name: String, prop_info: Dictionary, raw_value) -> Dictionary:
|
||||
# Case 1: node-typed export. Resolve a path string to the actual Node.
|
||||
if prop_info.type == TYPE_OBJECT and int(prop_info.get("hint", 0)) == PROPERTY_HINT_NODE_TYPE:
|
||||
if raw_value == null:
|
||||
node.set(property_name, null)
|
||||
return {"stored": null}
|
||||
if not (raw_value is String):
|
||||
return {"code": -32602, "error":
|
||||
"node-typed export '%s' expects a node path string (e.g. '../Target'), got %s" %
|
||||
[property_name, type_string(typeof(raw_value))]}
|
||||
var target := node.get_node_or_null(NodePath(raw_value))
|
||||
if target == null:
|
||||
return {"code": -32001, "error":
|
||||
"node-typed export '%s' on %s: no node at path '%s' (resolved relative to '%s'). " %
|
||||
[property_name, node.get_class(), raw_value, node.name] +
|
||||
"The target node must already exist in the scene."}
|
||||
node.set(property_name, target)
|
||||
var stored_node = node.get(property_name)
|
||||
if stored_node != target:
|
||||
return {"code": -32001, "error":
|
||||
"node-typed export '%s' on %s: assignment dropped — resolved node is a %s but the slot expects a %s" %
|
||||
[property_name, node.get_class(), target.get_class(), prop_info.get("hint_string", "Node")]}
|
||||
return {"stored": stored_node}
|
||||
|
||||
var coerced = Coerce.coerce(raw_value, prop_info.type)
|
||||
if coerced is Dictionary and coerced.has("_error"):
|
||||
return {"code": -32602, "error": coerced._error}
|
||||
|
||||
node.set(property_name, coerced)
|
||||
var stored = node.get(property_name)
|
||||
|
||||
# Case 2: setter routing. Some properties don't dispatch through node.set();
|
||||
# if the readback diverged and an explicit setter exists, go through it.
|
||||
if not _values_match(stored, coerced) and node.has_method("set_" + property_name):
|
||||
node.call("set_" + property_name, coerced)
|
||||
stored = node.get(property_name)
|
||||
|
||||
# Case 3a: Resource-typed slot silently rejected a non-Resource value.
|
||||
if coerced != null and stored == null and prop_info.type == TYPE_OBJECT:
|
||||
return {"code": -32001, "error":
|
||||
"property '%s' on %s was not accepted (assignment silently dropped — target expects a %s; passed value type didn't match)" %
|
||||
[property_name, node.get_class(), prop_info.get("hint_string", "Resource")]}
|
||||
|
||||
# Case 3b: a bool that didn't take. Limited to bool (the documented pitfall,
|
||||
# e.g. unique_name_in_owner) because other primitives legitimately transform
|
||||
# on set (name auto-deduplicates, ints clamp to range, floats lose precision),
|
||||
# which would make a strict match check fire false positives.
|
||||
if prop_info.type == TYPE_BOOL and not _values_match(stored, coerced):
|
||||
return {"code": -32001, "error":
|
||||
"property '%s' on %s was not accepted (set silently dropped: requested %s, stored %s)" %
|
||||
[property_name, node.get_class(), str(coerced), str(stored)]}
|
||||
|
||||
return {"stored": stored}
|
||||
|
||||
|
||||
# Equality check tolerant of GDScript's cross-type Variant comparison. Used to
|
||||
# detect whether an assignment actually took before deciding to route through a
|
||||
# setter or flag a silent drop.
|
||||
static func _values_match(a, b) -> bool:
|
||||
return a == b
|
||||
|
||||
|
||||
# scene.remove_node — operates on the currently-edited scene. Cannot remove the scene root.
|
||||
# Params: {node_path: "Player/Sprite2D"}
|
||||
static func remove_node(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path == "":
|
||||
return _err(-32602, "missing 'node_path'")
|
||||
if node_path == ".":
|
||||
return _err(-32602, "cannot remove scene root")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
|
||||
node.get_parent().remove_child(node)
|
||||
node.queue_free()
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"removed": node_path})
|
||||
|
||||
|
||||
# scene.reparent — move a node under a new parent in the currently-edited scene.
|
||||
# Params: {node_path, new_parent_path, keep_global_transform?: true}
|
||||
static func reparent(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var new_parent_path: String = params.get("new_parent_path", "")
|
||||
var keep_xform: bool = params.get("keep_global_transform", true)
|
||||
if node_path == "" or new_parent_path == "":
|
||||
return _err(-32602, "missing 'node_path' or 'new_parent_path'")
|
||||
if node_path == ".":
|
||||
return _err(-32602, "cannot reparent scene root")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
var new_parent: Node = root if new_parent_path == "." else root.get_node_or_null(new_parent_path)
|
||||
if new_parent == null:
|
||||
return _err(-32001, "new parent not found: %s" % new_parent_path)
|
||||
if new_parent == node or node.is_ancestor_of(new_parent):
|
||||
return _err(-32602, "cannot reparent under self or descendant (would create cycle)")
|
||||
|
||||
node.reparent(new_parent, keep_xform)
|
||||
# reparent() preserves owner in 4.3+, but be explicit so the node still serializes.
|
||||
node.owner = root
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"node_path": String(root.get_path_to(node))})
|
||||
|
||||
|
||||
# scene.open — open a scene in the editor, making it the currently-edited scene.
|
||||
# Params: {path: "res://Main.tscn"}
|
||||
static func open_scene(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
if path == "":
|
||||
return _err(-32602, "missing 'path'")
|
||||
if not ResourceLoader.exists(path, "PackedScene"):
|
||||
return _err(-32001, "scene not found: %s" % path)
|
||||
EditorInterface.open_scene_from_path(path)
|
||||
return _ok({"path": path})
|
||||
|
||||
|
||||
# scene.save — save the currently-edited scene. Pass 'path' to save-as (rebinds
|
||||
# the scene to that path).
|
||||
#
|
||||
# Fresh scenes (created via `scene.add_node`-only workflows with no backing file,
|
||||
# or never-saved manual scenes) have an empty `scene_file_path`. Godot's bare
|
||||
# `save_scene()` responds by opening the native Save-As file dialog — blocking
|
||||
# the editor and requiring a human click. We detect that case and return an
|
||||
# error instead, so the agent can react cleanly by re-calling with `path`.
|
||||
static func save_scene(params: Dictionary) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var path: String = params.get("path", "")
|
||||
var current_path: String = root.scene_file_path
|
||||
|
||||
if path == "" and current_path == "":
|
||||
return _err(-32602,
|
||||
"scene has never been saved and no 'path' was given. " +
|
||||
"Calling bare save would open the Save-As dialog and block the editor. " +
|
||||
"Re-call with 'path' set to a res:// target (e.g. 'res://scenes/foo.tscn').")
|
||||
|
||||
if path == "":
|
||||
var err := EditorInterface.save_scene()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: error %d" % err)
|
||||
else:
|
||||
# save_scene_as does NOT auto-create parent directories — it silently
|
||||
# fails if the target dir doesn't exist. Ensure the dir first.
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
# save_scene_as returns void in Godot 4.x — no error to check directly.
|
||||
EditorInterface.save_scene_as(path)
|
||||
|
||||
var final_path: String = EditorInterface.get_edited_scene_root().scene_file_path
|
||||
# If we passed a path but the scene still isn't bound, the save didn't land.
|
||||
# Surface that explicitly instead of returning {"path": ""} which looks like success.
|
||||
if path != "" and final_path == "":
|
||||
return _err(-32001,
|
||||
"save_scene_as('%s') did not bind the scene — verify the path is writable and ends in .tscn" % path)
|
||||
return _ok({"path": final_path})
|
||||
|
||||
|
||||
# scene.new — create a new .tscn file with a root node of the given type. By default
|
||||
# opens the new scene in the editor so subsequent scene.* calls operate on it.
|
||||
# Params: {path, root_type?: "Node", root_name?, overwrite?: false, open_after?: true}
|
||||
static func new_scene(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var root_type: String = params.get("root_type", "Node")
|
||||
var root_name: String = params.get("root_name", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
var open_after: bool = params.get("open_after", true)
|
||||
|
||||
if path == "" or not path.begins_with("res://") or not path.ends_with(".tscn"):
|
||||
return _err(-32602, "'path' must be 'res://...' ending in .tscn")
|
||||
if FileAccess.file_exists(path) and not overwrite:
|
||||
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
|
||||
if not ClassDB.class_exists(root_type):
|
||||
return _err(-32602, "unknown class: %s" % root_type)
|
||||
if not ClassDB.is_parent_class(root_type, "Node"):
|
||||
return _err(-32602, "root_type must derive from Node: %s" % root_type)
|
||||
if not ClassDB.can_instantiate(root_type):
|
||||
return _err(-32602, "class is not instantiable: %s" % root_type)
|
||||
|
||||
var root := ClassDB.instantiate(root_type) as Node
|
||||
if root == null:
|
||||
return _err(-32001, "failed to instantiate %s" % root_type)
|
||||
if root_name != "":
|
||||
root.name = root_name
|
||||
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
root.free()
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var packed := PackedScene.new()
|
||||
var pack_err := packed.pack(root)
|
||||
var final_name := String(root.name)
|
||||
root.free()
|
||||
if pack_err != OK:
|
||||
return _err(-32001, "PackedScene.pack failed: %d" % pack_err)
|
||||
var save_err := ResourceSaver.save(packed, path)
|
||||
if save_err != OK:
|
||||
return _err(-32001, "ResourceSaver.save failed: %d" % save_err)
|
||||
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
if open_after:
|
||||
# If the target path is already the currently-edited scene, open_scene_from_path
|
||||
# will just switch to the existing (stale) in-memory tab. Force a reload so the
|
||||
# freshly-written file is what the editor shows.
|
||||
var current_root := EditorInterface.get_edited_scene_root()
|
||||
if current_root != null and current_root.scene_file_path == path:
|
||||
EditorInterface.reload_scene_from_path(path)
|
||||
else:
|
||||
EditorInterface.open_scene_from_path(path)
|
||||
return _ok({"path": path, "root_type": root_type, "root_name": final_name})
|
||||
|
||||
|
||||
# scene.instance_packed — add an existing .tscn as a sub-scene child in the currently-edited scene.
|
||||
# Params: {scene_path, parent_path?: ".", name?}
|
||||
static func instance_packed(params: Dictionary) -> Dictionary:
|
||||
var scene_path: String = params.get("scene_path", "")
|
||||
var parent_path: String = params.get("parent_path", ".")
|
||||
var node_name: String = params.get("name", "")
|
||||
|
||||
if scene_path == "":
|
||||
return _err(-32602, "missing 'scene_path'")
|
||||
var packed := ResourceLoader.load(scene_path, "PackedScene") as PackedScene
|
||||
if packed == null:
|
||||
return _err(-32001, "failed to load scene: %s" % scene_path)
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var parent: Node = root if parent_path == "." or parent_path == "" else root.get_node_or_null(parent_path)
|
||||
if parent == null:
|
||||
return _err(-32001, "parent not found: %s" % parent_path)
|
||||
if scene_path == root.scene_file_path:
|
||||
return _err(-32602, "cannot instance a scene into itself (would recurse)")
|
||||
|
||||
var inst := packed.instantiate()
|
||||
if inst == null:
|
||||
return _err(-32001, "instantiate failed")
|
||||
|
||||
parent.add_child(inst)
|
||||
if node_name != "":
|
||||
inst.name = node_name
|
||||
# Owner is the scene root so the sub-scene instance serializes with this scene.
|
||||
inst.owner = root
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"node_path": String(root.get_path_to(inst)),
|
||||
"scene_path": scene_path,
|
||||
"name": String(inst.name),
|
||||
})
|
||||
|
||||
|
||||
# scene.get_property — read a property from a node. Mirror of set_property.
|
||||
# Params: {node_path, property}
|
||||
static func get_property(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var property_name: String = params.get("property", "")
|
||||
if node_path == "" or property_name == "":
|
||||
return _err(-32602, "missing 'node_path' or 'property'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
|
||||
var prop_type: int = TYPE_NIL
|
||||
var found := false
|
||||
for p in node.get_property_list():
|
||||
if p.name == property_name:
|
||||
prop_type = p.type
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
return _err(-32602, "property not found on %s: %s" % [node.get_class(), property_name])
|
||||
|
||||
return _ok({
|
||||
"node_path": node_path,
|
||||
"property": property_name,
|
||||
"value": Coerce.to_json(node.get(property_name)),
|
||||
"type": type_string(prop_type),
|
||||
})
|
||||
|
||||
|
||||
# scene.capture_screenshot — save a PNG of what the editor is currently showing
|
||||
# for the open scene. Captures the appropriate editor viewport (2D for 2D/Control
|
||||
# root nodes, 3D for Node3D roots). Includes the editor's grid/gizmos — this is
|
||||
# "what the user sees" rather than a clean render, which is actually useful for
|
||||
# the agent to spot layout problems.
|
||||
# Params: {output?: "res://screenshot.png"}
|
||||
# Returns: {path, width, height}
|
||||
static func capture_screenshot(params: Dictionary) -> Dictionary:
|
||||
var output_path: String = params.get("output", "")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open — open one first with scene.open")
|
||||
|
||||
var viewport: SubViewport
|
||||
if root is Node3D:
|
||||
viewport = EditorInterface.get_editor_viewport_3d(0)
|
||||
else:
|
||||
viewport = EditorInterface.get_editor_viewport_2d()
|
||||
if viewport == null:
|
||||
return _err(-32001, "could not obtain editor viewport")
|
||||
|
||||
var tex := viewport.get_texture()
|
||||
if tex == null:
|
||||
return _err(-32001, "viewport has no texture yet (try again after the editor renders a frame)")
|
||||
var img := tex.get_image()
|
||||
if img == null or img.is_empty():
|
||||
return _err(-32001, "failed to capture image from viewport")
|
||||
|
||||
if output_path == "":
|
||||
var base: String = "screenshot"
|
||||
if root.scene_file_path != "":
|
||||
base = root.scene_file_path.get_file().get_basename()
|
||||
output_path = "res://.godot/agent_tools/%s.png" % base
|
||||
|
||||
var dir_path := output_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed: %d" % derr)
|
||||
|
||||
var save_err := img.save_png(output_path)
|
||||
if save_err != OK:
|
||||
return _err(-32001, "save_png failed: %d" % save_err)
|
||||
|
||||
return _ok({
|
||||
"path": output_path,
|
||||
"width": img.get_width(),
|
||||
"height": img.get_height(),
|
||||
"note": "clean viewport capture (no editor grid/gizmos); empty scenes render as the viewport background color",
|
||||
})
|
||||
|
||||
|
||||
# scene.duplicate_node — clone a node (with its descendants) into the currently-edited scene.
|
||||
# Owner is set recursively so the duplicated subtree serializes with the scene.
|
||||
# Params: {node_path, new_name?, parent_path?: same parent}
|
||||
static func duplicate_node(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var new_name: String = params.get("new_name", "")
|
||||
var parent_path: String = params.get("parent_path", "")
|
||||
if node_path == "":
|
||||
return _err(-32602, "missing 'node_path'")
|
||||
if node_path == ".":
|
||||
return _err(-32602, "cannot duplicate scene root")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var src: Node = root.get_node_or_null(node_path)
|
||||
if src == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
|
||||
# DUPLICATE_SIGNALS | DUPLICATE_GROUPS | DUPLICATE_SCRIPTS — default flags.
|
||||
var copy: Node = src.duplicate(7)
|
||||
if copy == null:
|
||||
return _err(-32001, "duplicate() failed")
|
||||
|
||||
var parent: Node = src.get_parent() if parent_path == "" else root.get_node_or_null(parent_path)
|
||||
if parent == null:
|
||||
return _err(-32001, "parent not found: %s" % parent_path)
|
||||
parent.add_child(copy)
|
||||
if new_name != "":
|
||||
copy.name = new_name
|
||||
_set_owner_recursive(copy, root)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"source": node_path,
|
||||
"node_path": String(root.get_path_to(copy)),
|
||||
"name": String(copy.name),
|
||||
})
|
||||
|
||||
|
||||
static func _set_owner_recursive(node: Node, owner: Node) -> void:
|
||||
node.owner = owner
|
||||
for child in node.get_children():
|
||||
_set_owner_recursive(child, owner)
|
||||
|
||||
|
||||
# scene.build_tree — build a subtree in one call.
|
||||
# Spec is recursive: each entry is {type (required), name?, properties?, script?, children?}.
|
||||
# Collapses what would otherwise be N scene.add_node + M scene.set_property + K script.attach
|
||||
# calls into a single request, which matters for any UI-heavy scene where hand-writing the
|
||||
# tree by calling the atomic tools balloons into dozens of round trips.
|
||||
# Properties use the same Coerce rules as scene.set_property (Vectors from arrays, Resources
|
||||
# auto-loaded from res:// paths, etc.).
|
||||
# On any failure, all nodes created during the call are rolled back so the scene doesn't
|
||||
# end up partly built.
|
||||
# Params: {parent_path?: ".", nodes: [TreeNode, ...]}
|
||||
# TreeNode: {type: "ClassName", name?, properties?: {name: value}, script?: "res://...", children?: [TreeNode, ...]}
|
||||
static func build_tree(params: Dictionary) -> Dictionary:
|
||||
var parent_path: String = params.get("parent_path", ".")
|
||||
var nodes: Array = params.get("nodes", [])
|
||||
if nodes.is_empty():
|
||||
return _err(-32602, "missing 'nodes' (array of tree entries)")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var parent: Node = root if parent_path == "." or parent_path == "" else root.get_node_or_null(parent_path)
|
||||
if parent == null:
|
||||
return _err(-32001, "parent not found: %s" % parent_path)
|
||||
|
||||
var created_nodes: Array = []
|
||||
var created_paths: Array = []
|
||||
for spec in nodes:
|
||||
var err_msg := _build_subtree(spec, parent, root, created_nodes, created_paths)
|
||||
if err_msg != "":
|
||||
# Rollback: free everything created during this call in reverse order.
|
||||
for i in range(created_nodes.size() - 1, -1, -1):
|
||||
var n: Node = created_nodes[i]
|
||||
if is_instance_valid(n):
|
||||
if n.get_parent():
|
||||
n.get_parent().remove_child(n)
|
||||
n.queue_free()
|
||||
return _err(-32001, err_msg)
|
||||
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"parent_path": parent_path,
|
||||
"created": created_paths,
|
||||
"count": created_paths.size(),
|
||||
})
|
||||
|
||||
|
||||
# Recursive worker. Appends to the shared created_nodes/created_paths arrays so
|
||||
# the caller can roll back on failure. Returns "" on success, or an error message.
|
||||
static func _build_subtree(spec, parent: Node, scene_root: Node, created_nodes: Array, created_paths: Array) -> String:
|
||||
if typeof(spec) != TYPE_DICTIONARY:
|
||||
return "tree entry must be an object, got %s" % type_string(typeof(spec))
|
||||
|
||||
var node_type: String = spec.get("type", "")
|
||||
if node_type == "":
|
||||
return "tree entry missing 'type'"
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return "unknown class: %s" % node_type
|
||||
if not ClassDB.is_parent_class(node_type, "Node"):
|
||||
return "type must derive from Node: %s" % node_type
|
||||
if not ClassDB.can_instantiate(node_type):
|
||||
return "class is not instantiable: %s" % node_type
|
||||
|
||||
var inst := ClassDB.instantiate(node_type) as Node
|
||||
if inst == null:
|
||||
return "failed to instantiate %s" % node_type
|
||||
|
||||
var node_name: String = spec.get("name", "")
|
||||
if node_name != "":
|
||||
inst.name = node_name
|
||||
|
||||
parent.add_child(inst)
|
||||
inst.owner = scene_root
|
||||
created_nodes.append(inst)
|
||||
var this_path := String(scene_root.get_path_to(inst))
|
||||
created_paths.append(this_path)
|
||||
|
||||
# Attach a script before applying properties so script-exported properties
|
||||
# become settable in this same call.
|
||||
var script_path: String = spec.get("script", "")
|
||||
if script_path != "":
|
||||
var script := ResourceLoader.load(script_path, "Script") as Script
|
||||
if script == null:
|
||||
return "failed to load script '%s' for '%s'" % [script_path, this_path]
|
||||
inst.set_script(script)
|
||||
|
||||
var properties: Dictionary = spec.get("properties", {})
|
||||
if not properties.is_empty():
|
||||
var by_name: Dictionary = {}
|
||||
for p in inst.get_property_list():
|
||||
by_name[p.name] = p
|
||||
for prop_name in properties:
|
||||
if not by_name.has(prop_name):
|
||||
return "property not found on %s ('%s'): %s" % [node_type, this_path, prop_name]
|
||||
var prop_info: Dictionary = by_name[prop_name]
|
||||
if int(prop_info.get("usage", 0)) & PROPERTY_USAGE_READ_ONLY:
|
||||
return "property '%s' on '%s' is read-only" % [prop_name, this_path]
|
||||
var assigned := _assign_property(inst, prop_name, prop_info, properties[prop_name])
|
||||
if assigned.has("error"):
|
||||
return "property '%s' on '%s': %s" % [prop_name, this_path, assigned.error]
|
||||
|
||||
var children: Array = spec.get("children", [])
|
||||
for child_spec in children:
|
||||
var child_err := _build_subtree(child_spec, inst, scene_root, created_nodes, created_paths)
|
||||
if child_err != "":
|
||||
return child_err
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# scene.call_method — invoke a method on a node in the currently-edited scene.
|
||||
# Args are coerced based on the target method's declared parameter types
|
||||
# (same rules as scene.set_property — including res:// → Resource auto-load).
|
||||
# Return value is serialized via Coerce.to_json so the echo is unambiguous data.
|
||||
# Params: {node_path, method, args?: []}
|
||||
static func call_method(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var method_name: String = params.get("method", "")
|
||||
var args: Array = params.get("args", [])
|
||||
if node_path == "" or method_name == "":
|
||||
return _err(-32602, "missing 'node_path' or 'method'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not node.has_method(method_name):
|
||||
return _err(-32602, "method not found: %s.%s" % [node.get_class(), method_name])
|
||||
|
||||
# Not typed as Array — _coerce_args returns a Dictionary on error, and strict
|
||||
# typing would reject the assignment before we can branch on it.
|
||||
var coerced_args = _coerce_args(node, method_name, args)
|
||||
if coerced_args is Dictionary:
|
||||
return coerced_args # error passthrough
|
||||
var result = node.callv(method_name, coerced_args)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"node_path": node_path,
|
||||
"method": method_name,
|
||||
"return": Coerce.to_json(result),
|
||||
})
|
||||
|
||||
|
||||
# Look up the method's parameter types and coerce each arg accordingly.
|
||||
# Returns a coerced Array, or a Dictionary with {"error": ...} on failure.
|
||||
static func _coerce_args(obj: Object, method_name: String, args: Array):
|
||||
var method_info: Dictionary = {}
|
||||
for m in obj.get_method_list():
|
||||
if m.name == method_name:
|
||||
method_info = m
|
||||
break
|
||||
var arg_types: Array = method_info.get("args", [])
|
||||
var out: Array = []
|
||||
for i in args.size():
|
||||
if i < arg_types.size():
|
||||
var coerced = Coerce.coerce(args[i], arg_types[i].type)
|
||||
if coerced is Dictionary and coerced.has("_error"):
|
||||
return _err(-32602, "arg %d: %s" % [i, coerced._error])
|
||||
out.append(coerced)
|
||||
else:
|
||||
out.append(args[i])
|
||||
return out
|
||||
|
||||
|
||||
# scene.current — describe the currently-edited scene, or {open: false} if none.
|
||||
static func current(_params: Dictionary) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _ok({"open": false})
|
||||
return _ok({
|
||||
"open": true,
|
||||
"path": root.scene_file_path,
|
||||
"root_name": String(root.name),
|
||||
"root_class": root.get_class(),
|
||||
})
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b2d5doqppnqvn
|
||||
@@ -0,0 +1,171 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# script.create — write a new .gd file with an extends/class_name header.
|
||||
# Params: {path: "res://scripts/Player.gd", extends?: "Node", class_name?: "Player",
|
||||
# attach_to_node?: "Player", overwrite?: false}
|
||||
static func create(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var base_class: String = params.get("extends", "Node")
|
||||
var class_name_val: String = params.get("class_name", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
var attach_to: String = params.get("attach_to_node", "")
|
||||
|
||||
if path == "" or not path.begins_with("res://") or not path.ends_with(".gd"):
|
||||
return _err(-32602, "'path' must be 'res://...' ending in .gd")
|
||||
if FileAccess.file_exists(path) and not overwrite:
|
||||
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
|
||||
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var body := "extends %s\n" % base_class
|
||||
if class_name_val != "":
|
||||
body += "class_name %s\n" % class_name_val
|
||||
body += "\n"
|
||||
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return _err(-32001, "failed to open for write: %s" % path)
|
||||
f.store_string(body)
|
||||
f.close()
|
||||
|
||||
# Let the editor discover the new file so load() succeeds immediately.
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
|
||||
var data := {"path": path, "extends": base_class, "class_name": class_name_val}
|
||||
|
||||
if attach_to != "":
|
||||
var attach_res := _attach_script_to_node(attach_to, path)
|
||||
if attach_res.has("error"):
|
||||
return attach_res
|
||||
data["attached_to"] = attach_to
|
||||
|
||||
return _ok(data)
|
||||
|
||||
|
||||
# script.attach — attach an existing script to a node in the currently-edited scene.
|
||||
# Params: {node_path, script_path}
|
||||
static func attach(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var script_path: String = params.get("script_path", "")
|
||||
if node_path == "" or script_path == "":
|
||||
return _err(-32602, "missing 'node_path' or 'script_path'")
|
||||
return _attach_script_to_node(node_path, script_path)
|
||||
|
||||
|
||||
# script.patch — targeted edits to an existing .gd file. Two modes:
|
||||
# replacements: [{old, new}] — each 'old' must appear exactly once in the file;
|
||||
# fails atomically if any replacement is ambiguous
|
||||
# or missing. Useful for surgical edits.
|
||||
# full_source: string — overwrite the whole file (same as script.create
|
||||
# overwrite, kept here for symmetry).
|
||||
# After writing, the tool parse-checks the result via ResourceLoader.load(); if
|
||||
# that fails, the original file is restored and an error is returned.
|
||||
# Params: {path, replacements?: [{old, new}], full_source?: string, dry_run?: false}
|
||||
static func patch(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var replacements: Array = params.get("replacements", [])
|
||||
var full_source: String = params.get("full_source", "")
|
||||
var dry_run: bool = params.get("dry_run", false)
|
||||
|
||||
if path == "" or not path.ends_with(".gd"):
|
||||
return _err(-32602, "'path' must be a .gd file")
|
||||
if not FileAccess.file_exists(path):
|
||||
return _err(-32001, "file not found: %s" % path)
|
||||
if replacements.is_empty() and full_source == "":
|
||||
return _err(-32602, "provide either 'replacements' or 'full_source'")
|
||||
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return _err(-32001, "could not read %s" % path)
|
||||
var original := f.get_as_text()
|
||||
f.close()
|
||||
|
||||
var patched: String = original
|
||||
var applied: Array = []
|
||||
if full_source != "":
|
||||
patched = full_source
|
||||
applied.append("full_source")
|
||||
else:
|
||||
for entry in replacements:
|
||||
if typeof(entry) != TYPE_DICTIONARY or not entry.has("old") or not entry.has("new"):
|
||||
return _err(-32602, "each replacement needs {old, new}")
|
||||
var needle: String = str(entry.old)
|
||||
var replacement: String = str(entry.new)
|
||||
var count := patched.count(needle)
|
||||
if count == 0:
|
||||
return _err(-32602, "'old' string not found (would leave file unchanged): %s" % needle.substr(0, 60))
|
||||
if count > 1:
|
||||
return _err(-32602, "'old' string appears %d times — ambiguous. Include more context to make it unique: %s" % [count, needle.substr(0, 60)])
|
||||
patched = patched.replace(needle, replacement)
|
||||
applied.append("replaced %d chars" % needle.length())
|
||||
|
||||
if patched == original:
|
||||
return _ok({"path": path, "changed": false, "note": "patched content identical to original"})
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
"path": path,
|
||||
"changed": true,
|
||||
"applied": applied,
|
||||
"new_length": patched.length(),
|
||||
"original_length": original.length(),
|
||||
})
|
||||
|
||||
# Write, then parse-check. If parse fails, restore.
|
||||
var wf := FileAccess.open(path, FileAccess.WRITE)
|
||||
if wf == null:
|
||||
return _err(-32001, "could not write %s" % path)
|
||||
wf.store_string(patched)
|
||||
wf.close()
|
||||
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
# Force a fresh parse — ResourceLoader.load returns a cached/placeholder
|
||||
# Script for malformed sources, so we can't just null-check. Call reload()
|
||||
# which actually re-parses and returns an Error.
|
||||
var check := ResourceLoader.load(path, "Script", ResourceLoader.CACHE_MODE_REPLACE)
|
||||
var parse_ok := false
|
||||
if check != null and check is GDScript:
|
||||
parse_ok = (check.reload(true) == OK)
|
||||
if not parse_ok:
|
||||
# Restore original to keep the file loadable.
|
||||
var rf := FileAccess.open(path, FileAccess.WRITE)
|
||||
if rf:
|
||||
rf.store_string(original)
|
||||
rf.close()
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
return _err(-32001, "patched script failed to parse — changes rolled back. Check Godot's Output panel for the exact error.")
|
||||
|
||||
return _ok({
|
||||
"path": path,
|
||||
"changed": true,
|
||||
"applied": applied,
|
||||
"new_length": patched.length(),
|
||||
})
|
||||
|
||||
|
||||
static func _attach_script_to_node(node_path: String, script_path: String) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
var script := ResourceLoader.load(script_path, "Script") as Script
|
||||
if script == null:
|
||||
return _err(-32001, "failed to load script: %s" % script_path)
|
||||
node.set_script(script)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"node_path": node_path, "script": script_path})
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfewrbdp2o52o
|
||||
@@ -0,0 +1,148 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# signal.connect — operates on the currently-edited scene.
|
||||
# Params: {from: "Player", signal: "died", to: ".", method: "_on_player_died"}
|
||||
# Uses CONNECT_PERSIST so the connection is serialized into the .tscn as a [connection] block.
|
||||
static func connect_signal(params: Dictionary) -> Dictionary:
|
||||
var from_path: String = params.get("from", "")
|
||||
var signal_name: String = params.get("signal", "")
|
||||
var to_path: String = params.get("to", "")
|
||||
var method_name: String = params.get("method", "")
|
||||
|
||||
for required in ["from", "signal", "to", "method"]:
|
||||
if params.get(required, "") == "":
|
||||
return _err(-32602, "missing '%s'" % required)
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
|
||||
var source: Node = root if from_path == "." else root.get_node_or_null(from_path)
|
||||
if source == null:
|
||||
return _err(-32001, "source node not found: %s" % from_path)
|
||||
var target: Node = root if to_path == "." else root.get_node_or_null(to_path)
|
||||
if target == null:
|
||||
return _err(-32001, "target node not found: %s" % to_path)
|
||||
|
||||
if not source.has_signal(signal_name):
|
||||
return _err(-32602, "%s has no signal '%s'" % [source.get_class(), signal_name])
|
||||
if not target.has_method(method_name):
|
||||
return _err(-32602, "target has no method '%s' — attach a script with it first" % method_name)
|
||||
|
||||
# Arity check: number of non-default signal args must be <= target method arg count.
|
||||
var sig_info := _find_signal(source, signal_name)
|
||||
var method_info := _find_method(target, method_name)
|
||||
if sig_info and method_info:
|
||||
var sig_args: int = (sig_info.args as Array).size()
|
||||
var method_args: int = (method_info.args as Array).size()
|
||||
if method_args < sig_args:
|
||||
return _err(-32602, "arity mismatch: signal '%s' emits %d args, method '%s' accepts %d" %
|
||||
[signal_name, sig_args, method_name, method_args])
|
||||
|
||||
var callable := Callable(target, method_name)
|
||||
if source.is_connected(signal_name, callable):
|
||||
return _ok({"already_connected": true})
|
||||
|
||||
var err := source.connect(signal_name, callable, CONNECT_PERSIST)
|
||||
if err != OK:
|
||||
return _err(-32001, "connect() returned %d" % err)
|
||||
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"from": from_path,
|
||||
"signal": signal_name,
|
||||
"to": to_path,
|
||||
"method": method_name,
|
||||
})
|
||||
|
||||
|
||||
# signal.disconnect — remove a specific wiring in the currently-edited scene.
|
||||
# Params: {from, signal, to, method} — same shape as signal.connect.
|
||||
static func disconnect_signal(params: Dictionary) -> Dictionary:
|
||||
for required in ["from", "signal", "to", "method"]:
|
||||
if params.get(required, "") == "":
|
||||
return _err(-32602, "missing '%s'" % required)
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
|
||||
var from_path: String = params["from"]
|
||||
var to_path: String = params["to"]
|
||||
var source: Node = root if from_path == "." else root.get_node_or_null(from_path)
|
||||
var target: Node = root if to_path == "." else root.get_node_or_null(to_path)
|
||||
if source == null:
|
||||
return _err(-32001, "source not found: %s" % from_path)
|
||||
if target == null:
|
||||
return _err(-32001, "target not found: %s" % to_path)
|
||||
|
||||
var callable := Callable(target, params["method"])
|
||||
if not source.is_connected(params["signal"], callable):
|
||||
return _err(-32001, "no such connection")
|
||||
source.disconnect(params["signal"], callable)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"disconnected": true})
|
||||
|
||||
|
||||
# signal.list — list outgoing connections on a node in the currently-edited scene.
|
||||
# Params: {node_path, persistent_only?: false}
|
||||
# Returns connections with their flags so callers can distinguish editor-serialized
|
||||
# connections from runtime ones.
|
||||
static func list_signals(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var persistent_only: bool = params.get("persistent_only", false)
|
||||
if node_path == "":
|
||||
return _err(-32602, "missing 'node_path'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
|
||||
var outgoing: Array = []
|
||||
for sig in node.get_signal_list():
|
||||
var sig_name: String = sig.name
|
||||
for c in node.get_signal_connection_list(sig_name):
|
||||
var flags: int = c.flags
|
||||
var is_persistent: bool = bool(flags & CONNECT_PERSIST)
|
||||
if persistent_only and not is_persistent:
|
||||
continue
|
||||
var cb: Callable = c.callable
|
||||
var target: Object = cb.get_object()
|
||||
var target_path := ""
|
||||
if target is Node and (target == root or root.is_ancestor_of(target)):
|
||||
target_path = String(root.get_path_to(target))
|
||||
outgoing.append({
|
||||
"signal": sig_name,
|
||||
"to": target_path,
|
||||
"to_object": str(target) if target_path == "" else "",
|
||||
"method": cb.get_method(),
|
||||
"flags": flags,
|
||||
"persistent": is_persistent,
|
||||
})
|
||||
return _ok({"node_path": node_path, "outgoing": outgoing})
|
||||
|
||||
|
||||
static func _find_signal(obj: Object, name: String) -> Dictionary:
|
||||
for s in obj.get_signal_list():
|
||||
if s.name == name:
|
||||
return s
|
||||
return {}
|
||||
|
||||
|
||||
static func _find_method(obj: Object, name: String) -> Dictionary:
|
||||
for m in obj.get_method_list():
|
||||
if m.name == name:
|
||||
return m
|
||||
return {}
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cirt1dodck6pm
|
||||
@@ -0,0 +1,230 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# test.run — detect and run a GDScript test framework, return structured results.
|
||||
#
|
||||
# Supports GUT (addons/gut) and GdUnit4 (addons/gdUnit4). Auto-detects which is
|
||||
# installed; caller can force with 'framework'. Runs via headless subprocess so
|
||||
# it doesn't block the editor.
|
||||
#
|
||||
# Params: {
|
||||
# framework?: "auto" | "gut" | "gdunit4",
|
||||
# directory?: "res://test", # test directory; defaults by framework
|
||||
# pattern?: "test_*.gd", # file name pattern; defaults by framework
|
||||
# timeout_seconds?: 60,
|
||||
# }
|
||||
#
|
||||
# Returns: {
|
||||
# framework: "gut" | "gdunit4",
|
||||
# total, passed, failed, skipped,
|
||||
# failures: [{name, file, line, message}],
|
||||
# raw_output: string,
|
||||
# exit_code,
|
||||
# }
|
||||
static func run(params: Dictionary) -> Dictionary:
|
||||
var framework: String = params.get("framework", "auto")
|
||||
var directory: String = params.get("directory", "")
|
||||
var pattern: String = params.get("pattern", "")
|
||||
var timeout_seconds: int = int(params.get("timeout_seconds", 60))
|
||||
|
||||
if framework == "auto":
|
||||
framework = _detect_framework()
|
||||
if framework == "":
|
||||
return _err(-32001,
|
||||
"no test framework detected. Install GUT (addons/gut) or GdUnit4 (addons/gdUnit4), " +
|
||||
"or pass 'framework' explicitly.")
|
||||
|
||||
var runner_info := _runner_for(framework, directory, pattern)
|
||||
if runner_info.has("_error"):
|
||||
return _err(-32001, runner_info._error)
|
||||
|
||||
var exe := OS.get_executable_path()
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
var args: Array = ["--path", project_dir, "--headless"]
|
||||
for a in runner_info.args:
|
||||
args.append(str(a))
|
||||
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(exe, args, output, true)
|
||||
var stdout: String = ""
|
||||
if output.size() > 0:
|
||||
stdout = str(output[0])
|
||||
|
||||
var parsed: Dictionary
|
||||
match framework:
|
||||
"gut": parsed = _parse_gut_output(stdout)
|
||||
"gdunit4": parsed = _parse_gdunit4_output(stdout)
|
||||
_: parsed = {"total": 0, "passed": 0, "failed": 0, "skipped": 0, "failures": []}
|
||||
|
||||
parsed["framework"] = framework
|
||||
parsed["exit_code"] = exit_code
|
||||
# Strip ANSI escape codes from raw_output so the JSON wire is always clean.
|
||||
# Godot's JSON.stringify doesn't escape every control char; leaving \x1b in
|
||||
# the string breaks strict JSON parsers downstream.
|
||||
parsed["raw_output"] = _strip_ansi(stdout)
|
||||
parsed["command"] = "%s %s" % [exe, " ".join(args)]
|
||||
return _ok(parsed)
|
||||
|
||||
|
||||
static func _detect_framework() -> String:
|
||||
if DirAccess.dir_exists_absolute("res://addons/gut"):
|
||||
return "gut"
|
||||
if DirAccess.dir_exists_absolute("res://addons/gdUnit4"):
|
||||
return "gdunit4"
|
||||
return ""
|
||||
|
||||
|
||||
static func _runner_for(framework: String, directory: String, pattern: String) -> Dictionary:
|
||||
match framework:
|
||||
"gut":
|
||||
var gut_cmdln := "res://addons/gut/gut_cmdln.gd"
|
||||
if not FileAccess.file_exists(gut_cmdln):
|
||||
return {"_error": "GUT framework dir exists but gut_cmdln.gd not found — GUT install may be broken"}
|
||||
var args: Array = ["-s", gut_cmdln, "-gexit"]
|
||||
if directory != "":
|
||||
args.append("-gdir=%s" % directory)
|
||||
else:
|
||||
args.append("-gdir=res://test")
|
||||
if pattern != "":
|
||||
args.append("-gprefix=%s" % pattern)
|
||||
return {"args": args}
|
||||
"gdunit4":
|
||||
# GdUnit4 ships GdUnitRunner.gd for CLI execution.
|
||||
var runner := "res://addons/gdUnit4/bin/GdUnitCmdTool.gd"
|
||||
if not FileAccess.file_exists(runner):
|
||||
return {"_error": "GdUnit4 framework dir exists but GdUnitCmdTool.gd not found"}
|
||||
var args: Array = ["-s", runner]
|
||||
if directory != "":
|
||||
args.append("--add=%s" % directory)
|
||||
else:
|
||||
args.append("--add=res://test")
|
||||
return {"args": args}
|
||||
_:
|
||||
return {"_error": "unknown framework: %s (supported: gut, gdunit4)" % framework}
|
||||
|
||||
|
||||
# Parse GUT's text output. GUT's actual format (not the documented :-separated form):
|
||||
#
|
||||
# Totals
|
||||
# ------
|
||||
# Scripts 1
|
||||
# Tests 3
|
||||
# Passing Tests 2
|
||||
# Failing Tests 1
|
||||
# Pending Tests 0 <- optional, only if tests are pending
|
||||
# Asserts 2/3
|
||||
#
|
||||
# Failures appear under a per-file header in the "Run Summary" section:
|
||||
#
|
||||
# res://test/foo.gd
|
||||
# - test_failing_case
|
||||
# [Failed]: <assertion message>
|
||||
# at line 14
|
||||
#
|
||||
# Output has ANSI color escapes; strip them first so regex matches cleanly.
|
||||
static func _parse_gut_output(raw: String) -> Dictionary:
|
||||
var text := _strip_ansi(raw)
|
||||
var total := _gut_extract_count(text, "Tests")
|
||||
var passed := _gut_extract_count(text, "Passing Tests")
|
||||
var failed := _gut_extract_count(text, "Failing Tests")
|
||||
var skipped := _gut_extract_count(text, "Pending Tests")
|
||||
|
||||
var failures: Array = _extract_gut_failures(text)
|
||||
|
||||
return {"total": total, "passed": passed, "failed": failed, "skipped": skipped, "failures": failures}
|
||||
|
||||
|
||||
# Match either "Label 12" (space-aligned) or "Label: 12" (colon form) on its own line.
|
||||
static func _gut_extract_count(text: String, label: String) -> int:
|
||||
var re := RegEx.new()
|
||||
re.compile("(?m)^\\s*" + label.replace(" ", "\\s+") + "\\s*[:\\s]\\s*(\\d+)\\s*$")
|
||||
var m := re.search(text)
|
||||
if m == null:
|
||||
return 0
|
||||
return int(m.get_string(1))
|
||||
|
||||
|
||||
# Walk the text top-to-bottom, tracking current test file (file headers are plain
|
||||
# `res://...` lines), collecting failing tests (`- test_name` with a following
|
||||
# `[Failed]:` assertion and `at line N`).
|
||||
static func _extract_gut_failures(text: String) -> Array:
|
||||
var failures: Array = []
|
||||
var lines: PackedStringArray = text.split("\n")
|
||||
var current_file := ""
|
||||
var current_test := ""
|
||||
var current_message := ""
|
||||
var i := 0
|
||||
while i < lines.size():
|
||||
var line: String = lines[i].trim_suffix("\r").strip_edges(false, true) # strip only trailing
|
||||
if line.begins_with("res://") and line.ends_with(".gd"):
|
||||
current_file = line.strip_edges()
|
||||
i += 1
|
||||
continue
|
||||
var trimmed := line.strip_edges()
|
||||
if trimmed.begins_with("- "):
|
||||
current_test = trimmed.substr(2).strip_edges()
|
||||
current_message = ""
|
||||
elif trimmed.begins_with("[Failed]:"):
|
||||
current_message = trimmed.substr("[Failed]:".length()).strip_edges()
|
||||
elif trimmed.begins_with("at line"):
|
||||
var n_str := trimmed.substr("at line".length()).strip_edges()
|
||||
var line_num := int(n_str)
|
||||
if current_test != "" and current_file != "":
|
||||
failures.append({
|
||||
"name": current_test,
|
||||
"file": current_file,
|
||||
"line": line_num,
|
||||
"message": current_message,
|
||||
})
|
||||
current_test = ""
|
||||
current_message = ""
|
||||
i += 1
|
||||
return failures
|
||||
|
||||
|
||||
# Remove ANSI terminal escape codes (colors) from GUT's output so regex matches
|
||||
# don't get tripped up on things like "\x1b[32m".
|
||||
static func _strip_ansi(text: String) -> String:
|
||||
var re := RegEx.new()
|
||||
re.compile("\\x1b\\[[0-9;]*[A-Za-z]")
|
||||
return re.sub(text, "", true)
|
||||
|
||||
|
||||
# GdUnit4's default output ends with a summary like:
|
||||
# "| Tests | Executed | 12 | passed | 10 | skipped | 0 | failed | 2 | ... |"
|
||||
# and per-failure blocks with file:line.
|
||||
static func _parse_gdunit4_output(text: String) -> Dictionary:
|
||||
var total := 0
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
var skipped := 0
|
||||
|
||||
var summary_re := RegEx.new()
|
||||
summary_re.compile("Executed\\s*\\|\\s*(\\d+).*?passed\\s*\\|\\s*(\\d+).*?skipped\\s*\\|\\s*(\\d+).*?failed\\s*\\|\\s*(\\d+)")
|
||||
var m := summary_re.search(text)
|
||||
if m:
|
||||
total = int(m.get_string(1))
|
||||
passed = int(m.get_string(2))
|
||||
skipped = int(m.get_string(3))
|
||||
failed = int(m.get_string(4))
|
||||
|
||||
var failures: Array = []
|
||||
var fail_re := RegEx.new()
|
||||
fail_re.compile("(?m)FAILED\\s+(.+?)\\s*\\(at:\\s*(.+?):(\\d+)")
|
||||
for fm in fail_re.search_all(text):
|
||||
failures.append({
|
||||
"name": fm.get_string(1),
|
||||
"file": fm.get_string(2),
|
||||
"line": int(fm.get_string(3)),
|
||||
"message": "",
|
||||
})
|
||||
|
||||
return {"total": total, "passed": passed, "failed": failed, "skipped": skipped, "failures": failures}
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://blc15tr1vxiek
|
||||
@@ -0,0 +1,110 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# theme.* — thin wrappers around Godot's awkward Theme API. Theme uses the
|
||||
# three-argument shape `(item_name, type_name, value)` where type_name is a
|
||||
# Control subclass like "Button", "Label", "LineEdit". resource.call_method
|
||||
# works but these helpers save the lookup.
|
||||
|
||||
# theme.set_color — {path, item, type, color}
|
||||
# color accepts [r,g,b(,a)] or '#rrggbb(aa)' (Coerce handles it).
|
||||
static func set_color(params: Dictionary) -> Dictionary:
|
||||
return _set_entry(params, "color", "set_color", TYPE_COLOR)
|
||||
|
||||
|
||||
# theme.set_constant — {path, item, type, value: int}
|
||||
static func set_constant(params: Dictionary) -> Dictionary:
|
||||
return _set_entry(params, "constant", "set_constant", TYPE_INT)
|
||||
|
||||
|
||||
# theme.set_font_size — {path, item, type, value: int}
|
||||
static func set_font_size(params: Dictionary) -> Dictionary:
|
||||
return _set_entry(params, "font_size", "set_font_size", TYPE_INT)
|
||||
|
||||
|
||||
# theme.set_stylebox_flat — {path, item, type, properties: {bg_color, corner_radius_*, border_width_*, ...}}
|
||||
# Creates (or replaces) a StyleBoxFlat with the given properties and assigns
|
||||
# it to theme.<item>.<type>. Saves the two-step "create stylebox, set props,
|
||||
# assign" dance.
|
||||
static func set_stylebox_flat(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var item_name: String = params.get("item", "")
|
||||
var type_name: String = params.get("type", "")
|
||||
var props: Dictionary = params.get("properties", {})
|
||||
if path == "" or item_name == "" or type_name == "":
|
||||
return _err(-32602, "missing 'path', 'item', or 'type'")
|
||||
|
||||
var theme := ResourceLoader.load(path) as Theme
|
||||
if theme == null:
|
||||
return _err(-32001, "failed to load theme: %s" % path)
|
||||
|
||||
var sb := StyleBoxFlat.new()
|
||||
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
|
||||
for k in props:
|
||||
var prop_info := _find_property(sb, k)
|
||||
if prop_info.is_empty():
|
||||
return _err(-32602, "StyleBoxFlat has no property '%s'" % k)
|
||||
var coerced = Coerce.coerce(props[k], prop_info.type)
|
||||
if coerced is Dictionary and coerced.has("_error"):
|
||||
return _err(-32602, "property %s: %s" % [k, coerced._error])
|
||||
sb.set(k, coerced)
|
||||
|
||||
theme.set_stylebox(item_name, type_name, sb)
|
||||
var save_err := ResourceSaver.save(theme, path)
|
||||
if save_err != OK:
|
||||
return _err(-32001, "theme save failed: %d" % save_err)
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
return _ok({
|
||||
"path": path,
|
||||
"item": item_name,
|
||||
"type": type_name,
|
||||
"applied_properties": props.keys(),
|
||||
})
|
||||
|
||||
|
||||
static func _set_entry(params: Dictionary, expected_kind: String, setter_name: String, expected_type: int) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var item_name: String = params.get("item", "")
|
||||
var type_name: String = params.get("type", "")
|
||||
if path == "" or item_name == "" or type_name == "":
|
||||
return _err(-32602, "missing 'path', 'item', or 'type'")
|
||||
|
||||
var theme := ResourceLoader.load(path) as Theme
|
||||
if theme == null:
|
||||
return _err(-32001, "failed to load theme: %s" % path)
|
||||
|
||||
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
|
||||
var key := "color" if expected_kind == "color" else "value"
|
||||
var raw = params.get(key)
|
||||
if raw == null:
|
||||
return _err(-32602, "missing '%s'" % key)
|
||||
var coerced = Coerce.coerce(raw, expected_type)
|
||||
if coerced is Dictionary and coerced.has("_error"):
|
||||
return _err(-32602, coerced._error)
|
||||
|
||||
theme.call(setter_name, item_name, type_name, coerced)
|
||||
var save_err := ResourceSaver.save(theme, path)
|
||||
if save_err != OK:
|
||||
return _err(-32001, "theme save failed: %d" % save_err)
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
return _ok({
|
||||
"path": path,
|
||||
"item": item_name,
|
||||
"type": type_name,
|
||||
"kind": expected_kind,
|
||||
})
|
||||
|
||||
|
||||
static func _find_property(obj: Object, name: String) -> Dictionary:
|
||||
for p in obj.get_property_list():
|
||||
if p.name == name:
|
||||
return p
|
||||
return {}
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5p1j8mfeeeav
|
||||
@@ -0,0 +1,92 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Read-side tools for the `user://` data directory — where games write save files,
|
||||
# custom-level JSON, settings, etc. Agents couldn't peek at these without running
|
||||
# the editor and eyeballing. Kept separate from fs.list (which is res://-only)
|
||||
# because the semantics are different: user:// is runtime-written state, res:// is
|
||||
# project source.
|
||||
|
||||
# user_fs.read — return a text file's content from user://.
|
||||
# Params: {path}
|
||||
static func read(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
if path == "":
|
||||
return _err(-32602, "missing 'path'")
|
||||
if not path.begins_with("user://"):
|
||||
return _err(-32602, "path must begin with 'user://'")
|
||||
if not _within_sandbox(path, "user://"):
|
||||
return _err(-32602, "path escapes the user:// sandbox ('..' traversal not allowed): %s" % path)
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return _err(-32001, "failed to open: %s (error %d)" % [path, FileAccess.get_open_error()])
|
||||
var size_bytes := f.get_length()
|
||||
var content := f.get_as_text()
|
||||
f.close()
|
||||
return _ok({
|
||||
"path": path,
|
||||
"content": content,
|
||||
"size_bytes": size_bytes,
|
||||
})
|
||||
|
||||
|
||||
# user_fs.list — list entries under a user:// directory.
|
||||
# Params: {dir?: "user://", recursive?: false}
|
||||
static func list(params: Dictionary) -> Dictionary:
|
||||
var dir_path: String = params.get("dir", "user://")
|
||||
var recursive: bool = params.get("recursive", false)
|
||||
|
||||
if not dir_path.begins_with("user://"):
|
||||
return _err(-32602, "dir must begin with 'user://'")
|
||||
if not _within_sandbox(dir_path, "user://"):
|
||||
return _err(-32602, "dir escapes the user:// sandbox ('..' traversal not allowed): %s" % dir_path)
|
||||
|
||||
var files: Array = []
|
||||
var dirs: Array = []
|
||||
_walk(dir_path, files, dirs, recursive)
|
||||
|
||||
files.sort()
|
||||
dirs.sort()
|
||||
return _ok({
|
||||
"dir": dir_path,
|
||||
"files": files,
|
||||
"dirs": dirs,
|
||||
"count": files.size(),
|
||||
})
|
||||
|
||||
|
||||
static func _walk(dir_path: String, out_files: Array, out_dirs: Array, recursive: bool) -> void:
|
||||
var d := DirAccess.open(dir_path)
|
||||
if d == null:
|
||||
return
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var name := d.get_next()
|
||||
if name == "":
|
||||
break
|
||||
if name.begins_with("."):
|
||||
continue
|
||||
var full := dir_path.path_join(name)
|
||||
if d.current_is_dir():
|
||||
out_dirs.append(full)
|
||||
if recursive:
|
||||
_walk(full, out_files, out_dirs, recursive)
|
||||
else:
|
||||
out_files.append(full)
|
||||
d.list_dir_end()
|
||||
|
||||
|
||||
# Path-traversal guard — see fs_tools._within_sandbox. `begins_with("user://")`
|
||||
# is not a sandbox; `..` segments resolve against the real filesystem.
|
||||
static func _within_sandbox(path: String, prefix: String) -> bool:
|
||||
var root := ProjectSettings.globalize_path(prefix).simplify_path().trim_suffix("/")
|
||||
var abs := ProjectSettings.globalize_path(path).simplify_path()
|
||||
return abs == root or abs.begins_with(root + "/")
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://btej8otgjy0kj
|
||||
Reference in new issue
Block a user