Initial Void Bastion 0.20 prototype

This commit is contained in:
Emporex committed 2026-10-03 21:28:56 +02:00
commit 7362ca0f02
251 files changed
+29688

No files matched your search

+734
View File
@@ -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}}