Files
spellforge/addons/godot_mcp/mcp_game_inspector_service.gd
T
2026-07-20 10:56:52 +08:00

1762 lines
57 KiB
GDScript

## Autoload injected by Godot MCP Pro plugin at runtime.
## Handles runtime game inspection: scene tree, node properties, frame capture, property monitoring.
extends Node
const REQUEST_PATH := "user://mcp_game_request"
const RESPONSE_PATH := "user://mcp_game_response"
enum State { IDLE, CAPTURING_FRAMES, MONITORING, RECORDING, MOVING_TO, WATCHING_SIGNALS }
var _state := State.IDLE
var _pending_command: bool = false # Crash recovery flag
# Frame capture state
var _capture_frames_remaining: int = 0
var _capture_frame_interval: int = 1
var _capture_frame_counter: int = 0
var _capture_half_res: bool = true
var _captured_images: Array = [] # Array of base64 strings
var _capture_node_path: String = "" # Optional node to track per frame
var _capture_node_props: Array = [] # Properties to snapshot per frame
var _capture_frame_data: Array = [] # Array of per-frame node snapshots
# Recording state
var _recording_events: Array = []
var _recording_start_msec: int = 0
# Monitor state
var _monitor_node_path: String = ""
var _monitor_properties: Array = []
var _monitor_frames_remaining: int = 0
var _monitor_frame_interval: int = 1
var _monitor_frame_counter: int = 0
var _monitor_timeline: Array = [] # Array of sample dicts
# Signal watch state
var _watch_nodes: Array = [] # Array of node paths being watched
var _watch_signal_filter: Array = [] # Optional signal name filter
var _watch_log: Array = [] # Array of {time_ms, node, signal, args}
var _watch_start_msec: int = 0
var _watch_duration_ms: int = 5000
var _watch_connections: Array = [] # Array of {node, signal, callable} for cleanup
# Move-to state
var _moveto_target: Vector3 = Vector3.ZERO
var _moveto_player: Node3D = null
var _moveto_camera_pivot: Node3D = null
var _moveto_arrival_radius: float = 1.5
var _moveto_timeout: float = 15.0
var _moveto_elapsed: float = 0.0
var _moveto_run: bool = false
var _moveto_look_at: bool = true
var _moveto_keys_held: Array = [] # Track injected keys for guaranteed release
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(_delta: float) -> void:
# Crash recovery: if a command was in progress but never wrote a response
if _pending_command and not FileAccess.file_exists(REQUEST_PATH) and not FileAccess.file_exists(RESPONSE_PATH):
push_warning("[MCP] Recovered from crashed command — writing error response")
_pending_command = false
_state = State.IDLE
# Signal editor plugin to auto-press debugger Continue
var flag := FileAccess.open("user://mcp_debugger_continue", FileAccess.WRITE)
if flag:
flag.close()
_write_response({"error": "Command crashed (runtime error). Check Godot debugger."})
return
match _state:
State.IDLE:
if FileAccess.file_exists(REQUEST_PATH):
_handle_request()
State.CAPTURING_FRAMES:
_process_capture()
State.MONITORING:
_process_monitor()
State.RECORDING:
if FileAccess.file_exists(REQUEST_PATH):
_handle_request()
State.MOVING_TO:
_process_move_to(_delta)
State.WATCHING_SIGNALS:
_process_watch_signals()
# ── Request handling ──────────────────────────────────────────────────────────
func _handle_request() -> void:
var file := FileAccess.open(REQUEST_PATH, FileAccess.READ)
if file == null:
return
var text := file.get_as_text()
file.close()
DirAccess.remove_absolute(REQUEST_PATH)
var parsed = JSON.parse_string(text)
if parsed == null or not parsed is Dictionary:
_write_response({"error": "Invalid request JSON"})
return
# Abort any in-progress operation
_state = State.IDLE
_pending_command = true
var command: String = parsed.get("command", "")
var params: Dictionary = parsed.get("params", {})
match command:
"get_scene_tree":
_cmd_get_scene_tree(params)
"get_node_properties":
_cmd_get_node_properties(params)
"set_node_property":
_cmd_set_node_property(params)
"capture_frames":
_cmd_capture_frames(params)
"monitor_properties":
_cmd_monitor_properties(params)
"execute_script":
_cmd_execute_script(params)
"start_recording":
_cmd_start_recording(params)
"stop_recording":
_cmd_stop_recording(params)
"replay_recording":
_cmd_replay_recording(params)
"find_nodes_by_script":
_cmd_find_nodes_by_script(params)
"get_autoload":
_cmd_get_autoload(params)
"batch_get_properties":
_cmd_batch_get_properties(params)
"find_ui_elements":
_cmd_find_ui_elements(params)
"click_button_by_text":
_cmd_click_button_by_text(params)
"wait_for_node":
_cmd_wait_for_node(params)
"find_nearby_nodes":
_cmd_find_nearby_nodes(params)
"navigate_to":
_cmd_navigate_to(params)
"move_to":
_cmd_move_to(params)
"watch_signals":
_cmd_watch_signals(params)
"assert_node_state":
_cmd_assert_node_state(params)
"get_performance_monitors":
_cmd_get_performance_monitors(params)
_:
_write_response({"error": "Unknown command: %s" % command})
# ── get_scene_tree ────────────────────────────────────────────────────────────
func _cmd_get_scene_tree(params: Dictionary) -> void:
var root := get_tree().current_scene
if root == null:
_write_response({"error": "No current scene"})
return
var max_depth: int = params.get("max_depth", -1)
var script_filter: String = params.get("script_filter", "")
var type_filter: String = params.get("type_filter", "")
var named_only: bool = params.get("named_only", false)
var has_filter: bool = not script_filter.is_empty() or not type_filter.is_empty() or named_only
if has_filter:
var tree: Variant = _build_filtered_node_tree(root, max_depth, script_filter, type_filter, named_only)
if tree == null:
_write_response({"tree": null, "message": "No nodes matched the filter"})
else:
_write_response({"tree": tree})
else:
var tree := _build_node_tree(root, max_depth)
_write_response({"tree": tree})
func _build_node_tree(node: Node, max_depth: int, current_depth: int = 0) -> Dictionary:
var result := {
"name": node.name,
"type": node.get_class(),
"path": str(node.get_path()),
}
var script: Script = node.get_script()
if script:
result["script"] = script.resource_path
if max_depth == -1 or current_depth < max_depth:
var children: Array = []
for child in node.get_children():
children.append(_build_node_tree(child, max_depth, current_depth + 1))
if not children.is_empty():
result["children"] = children
return result
## Build a filtered tree. Returns null if this subtree has no matches.
func _build_filtered_node_tree(node: Node, max_depth: int, script_filter: String, type_filter: String, named_only: bool, current_depth: int = 0) -> Variant:
var node_matches := _node_matches_filter(node, script_filter, type_filter, named_only)
# Build children first to check if any descendant matches
var matched_children: Array = []
if max_depth == -1 or current_depth < max_depth:
for child in node.get_children():
var child_tree: Variant = _build_filtered_node_tree(child, max_depth, script_filter, type_filter, named_only, current_depth + 1)
if child_tree != null:
matched_children.append(child_tree)
# Include this node if it matches or if any descendant matches
if not node_matches and matched_children.is_empty():
return null
var result := {
"name": node.name,
"type": node.get_class(),
"path": str(node.get_path()),
}
var script: Script = node.get_script()
if script:
result["script"] = script.resource_path
if not matched_children.is_empty():
result["children"] = matched_children
return result
func _node_matches_filter(node: Node, script_filter: String, type_filter: String, named_only: bool) -> bool:
if named_only and (node.name as String).begins_with("@"):
return false
if not type_filter.is_empty() and not node.is_class(type_filter):
return false
if not script_filter.is_empty():
var script: Script = node.get_script()
if script == null:
return false
if not script.resource_path.to_lower().contains(script_filter.to_lower()):
return false
return true
# ── get_node_properties ───────────────────────────────────────────────────────
func _cmd_get_node_properties(params: Dictionary) -> void:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
_write_response({"error": "node_path is required"})
return
var node := get_node_or_null(NodePath(node_path))
if node == null:
_write_response({"error": "Node not found: %s" % node_path})
return
var filter: Array = params.get("properties", [])
var props: Dictionary = {}
if filter.is_empty():
for prop_info in node.get_property_list():
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
if not (usage & PROPERTY_USAGE_EDITOR):
continue
if prop_name.begins_with("_") or prop_name == "script":
continue
props[prop_name] = _serialize_value(node.get(prop_name))
else:
for prop_name: String in filter:
var value: Variant = node.get(prop_name)
props[prop_name] = _serialize_value(value)
_write_response({
"node_path": str(node.get_path()),
"type": node.get_class(),
"properties": props,
})
# ── capture_frames ────────────────────────────────────────────────────────────
func _cmd_capture_frames(params: Dictionary) -> void:
_pending_command = false # Async command — don't trigger crash recovery
var count: int = clampi(params.get("count", 5), 1, 30)
var interval: int = maxi(params.get("frame_interval", 10), 1)
_capture_half_res = params.get("half_resolution", true)
# Optional node_data tracking
_capture_node_path = ""
_capture_node_props = []
_capture_frame_data.clear()
var node_data: Dictionary = params.get("node_data", {})
if not node_data.is_empty():
_capture_node_path = node_data.get("node_path", "")
_capture_node_props = node_data.get("properties", [])
_captured_images.clear()
_capture_frames_remaining = count
_capture_frame_interval = interval
_capture_frame_counter = 0
_state = State.CAPTURING_FRAMES
_capture_one_frame()
func _process_capture() -> void:
if FileAccess.file_exists(REQUEST_PATH):
_state = State.IDLE
_handle_request()
return
_capture_frame_counter += 1
if _capture_frame_counter >= _capture_frame_interval:
_capture_frame_counter = 0
_capture_one_frame()
func _capture_one_frame() -> void:
var viewport := get_viewport()
if viewport == null:
_finish_capture()
return
var image := viewport.get_texture().get_image()
if image == null:
_finish_capture()
return
if _capture_half_res:
var new_size := image.get_size() / 2
image.resize(new_size.x, new_size.y, Image.INTERPOLATE_BILINEAR)
var png_buffer := image.save_png_to_buffer()
_captured_images.append(Marshalls.raw_to_base64(png_buffer))
# Snapshot node properties if tracking
if not _capture_node_path.is_empty() and not _capture_node_props.is_empty():
var snap := {}
var node := get_tree().root.get_node_or_null(_capture_node_path)
if node:
for prop_name in _capture_node_props:
var val = node.get(prop_name)
snap[prop_name] = _serialize_value(val)
_capture_frame_data.append(snap)
_capture_frames_remaining -= 1
if _capture_frames_remaining <= 0:
_finish_capture()
func _finish_capture() -> void:
_state = State.IDLE
var viewport := get_viewport()
var w := 0
var h := 0
if viewport:
var size := viewport.get_visible_rect().size
if _capture_half_res:
size /= 2
w = int(size.x)
h = int(size.y)
var response := {
"frames": _captured_images,
"count": _captured_images.size(),
"width": w,
"height": h,
"half_resolution": _capture_half_res,
}
if not _capture_frame_data.is_empty():
response["frame_data"] = _capture_frame_data
_write_response(response)
_captured_images.clear()
_capture_frame_data.clear()
# ── monitor_properties ────────────────────────────────────────────────────────
func _cmd_monitor_properties(params: Dictionary) -> void:
_pending_command = false # Async command — don't trigger crash recovery
_monitor_node_path = params.get("node_path", "")
_monitor_properties = params.get("properties", [])
if _monitor_node_path.is_empty() or _monitor_properties.is_empty():
_write_response({"error": "node_path and properties are required"})
return
var frame_count: int = clampi(params.get("frame_count", 60), 1, 600)
var interval: int = maxi(params.get("frame_interval", 1), 1)
_monitor_timeline.clear()
_monitor_frames_remaining = frame_count
_monitor_frame_interval = interval
_monitor_frame_counter = 0
_state = State.MONITORING
_sample_one_frame()
func _process_monitor() -> void:
if FileAccess.file_exists(REQUEST_PATH):
_state = State.IDLE
_handle_request()
return
_monitor_frame_counter += 1
if _monitor_frame_counter >= _monitor_frame_interval:
_monitor_frame_counter = 0
_sample_one_frame()
func _sample_one_frame() -> void:
var sample: Dictionary = {}
var node := get_node_or_null(NodePath(_monitor_node_path))
if node == null:
for prop_name: String in _monitor_properties:
sample[prop_name] = null
else:
for prop_name: String in _monitor_properties:
sample[prop_name] = _serialize_value(node.get(prop_name))
_monitor_timeline.append(sample)
_monitor_frames_remaining -= 1
if _monitor_frames_remaining <= 0:
_finish_monitor()
func _finish_monitor() -> void:
_state = State.IDLE
_write_response({
"node_path": _monitor_node_path,
"properties": _monitor_properties,
"samples": _monitor_timeline,
"sample_count": _monitor_timeline.size(),
"frame_interval": _monitor_frame_interval,
})
_monitor_timeline.clear()
# ── watch_signals ────────────────────────────────────────────────────────────
func _cmd_watch_signals(params: Dictionary) -> void:
_pending_command = false # Async command — don't trigger crash recovery
if not params.has("node_paths") or not params["node_paths"] is Array:
_write_response({"error": "node_paths array is required"})
return
var node_paths: Array = params["node_paths"]
if node_paths.is_empty():
_write_response({"error": "node_paths array is empty"})
return
_watch_signal_filter = params.get("signal_filter", []) if params.has("signal_filter") and params["signal_filter"] is Array else []
_watch_duration_ms = clampi(params.get("duration_ms", 5000), 500, 30000)
_watch_log.clear()
_watch_connections.clear()
_watch_nodes = node_paths
# Connect to signals on each node
var connected_count: int = 0
for node_path_str: String in node_paths:
var node := get_node_or_null(NodePath(node_path_str))
if node == null:
_watch_log.append({"warning": "Node not found: %s" % node_path_str})
continue
for sig_info: Dictionary in node.get_signal_list():
var sig_name: String = sig_info["name"]
# Apply filter if specified
if not _watch_signal_filter.is_empty():
var match_found := false
for filter_str: String in _watch_signal_filter:
if sig_name.contains(filter_str):
match_found = true
break
if not match_found:
continue
# Create a callable matched to the signal's argument count
var arg_count: int = sig_info["args"].size()
var cb := _make_signal_callback(node_path_str, sig_name, arg_count)
if cb.is_valid() and not node.is_connected(sig_name, cb):
node.connect(sig_name, cb)
_watch_connections.append({"node": node, "signal": sig_name, "callable": cb})
connected_count += 1
if connected_count == 0 and _watch_log.is_empty():
_write_response({"error": "No signals connected. Check node_paths and signal_filter."})
return
_watch_start_msec = Time.get_ticks_msec()
_state = State.WATCHING_SIGNALS
func _on_signal_fired(node_path_str: String, sig_name: String, args: Array) -> void:
var elapsed: int = Time.get_ticks_msec() - _watch_start_msec
var entry: Dictionary = {
"time_ms": elapsed,
"node": node_path_str,
"signal": sig_name,
}
if not args.is_empty():
var serialized: Array = []
for a: Variant in args:
serialized.append(_serialize_value(a))
entry["args"] = serialized
_watch_log.append(entry)
func _make_signal_callback(node_path_str: String, sig_name: String, arg_count: int) -> Callable:
var np := node_path_str
var sn := sig_name
match arg_count:
0:
return func() -> void: _on_signal_fired(np, sn, [])
1:
return func(a: Variant) -> void: _on_signal_fired(np, sn, [a])
2:
return func(a: Variant, b: Variant) -> void: _on_signal_fired(np, sn, [a, b])
3:
return func(a: Variant, b: Variant, c: Variant) -> void: _on_signal_fired(np, sn, [a, b, c])
4:
return func(a: Variant, b: Variant, c: Variant, d: Variant) -> void: _on_signal_fired(np, sn, [a, b, c, d])
_:
# For 5+ args, drop extra args and log without them
var cb := func() -> void: _on_signal_fired(np, sn, [])
return cb.unbind(arg_count)
func _process_watch_signals() -> void:
# Check for abort (new request)
if FileAccess.file_exists(REQUEST_PATH):
_finish_watch_signals()
_state = State.IDLE
_handle_request()
return
var elapsed: int = Time.get_ticks_msec() - _watch_start_msec
if elapsed >= _watch_duration_ms:
_finish_watch_signals()
func _finish_watch_signals() -> void:
# Disconnect all watchers
for conn: Dictionary in _watch_connections:
var node: Node = conn["node"] as Node
if is_instance_valid(node):
var sig_name: String = conn["signal"]
var cb: Callable = conn["callable"]
if node.is_connected(sig_name, cb):
node.disconnect(sig_name, cb)
_watch_connections.clear()
_state = State.IDLE
_write_response({
"node_paths": _watch_nodes,
"signal_filter": _watch_signal_filter,
"duration_ms": _watch_duration_ms,
"events": _watch_log,
"event_count": _watch_log.size(),
})
_watch_log.clear()
# ── set_node_property ─────────────────────────────────────────────────────────
func _cmd_set_node_property(params: Dictionary) -> void:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
_write_response({"error": "node_path is required"})
return
var property: String = params.get("property", "")
if property.is_empty():
_write_response({"error": "property is required"})
return
if not params.has("value"):
_write_response({"error": "value is required"})
return
var node := get_node_or_null(NodePath(node_path))
if node == null:
_write_response({"error": "Node not found: %s" % node_path})
return
var old_value: Variant = node.get(property)
var raw_value: Variant = params.get("value")
var parsed_value: Variant = _parse_value_for_type(raw_value, typeof(old_value))
node.set(property, parsed_value)
var new_value: Variant = node.get(property)
_write_response({
"node_path": str(node.get_path()),
"property": property,
"old_value": _serialize_value(old_value),
"new_value": _serialize_value(new_value),
})
func _parse_value_for_type(raw: Variant, target_type: int) -> Variant:
if typeof(raw) == target_type:
return raw
# Handle Dictionary → Vector2/Vector3/Color conversion
if raw is Dictionary:
var dict: Dictionary = raw
match target_type:
TYPE_VECTOR3:
return Vector3(
float(dict.get("x", 0)),
float(dict.get("y", 0)),
float(dict.get("z", 0))
)
TYPE_VECTOR3I:
return Vector3i(
int(dict.get("x", 0)),
int(dict.get("y", 0)),
int(dict.get("z", 0))
)
TYPE_VECTOR2:
return Vector2(
float(dict.get("x", 0)),
float(dict.get("y", 0))
)
TYPE_VECTOR2I:
return Vector2i(
int(dict.get("x", 0)),
int(dict.get("y", 0))
)
TYPE_COLOR:
return Color(
float(dict.get("r", 0)),
float(dict.get("g", 0)),
float(dict.get("b", 0)),
float(dict.get("a", 1))
)
return raw
if raw is String:
var raw_str: String = raw
if raw_str.begins_with("#"):
return Color.html(raw_str)
var expr := Expression.new()
var err := expr.parse(raw_str)
if err == OK:
var result: Variant = expr.execute()
if not expr.has_execute_failed():
return result
return raw_str
if raw is float and target_type == TYPE_INT:
return int(raw)
if raw is int and target_type == TYPE_FLOAT:
return float(raw)
return raw
# ── execute_script ────────────────────────────────────────────────────────────
func _cmd_execute_script(params: Dictionary) -> void:
var code: String = params.get("code", "")
if code.is_empty():
_write_response({"error": "code is required"})
return
# Normalize indentation: convert leading spaces to tabs
var raw_lines := code.split("\n")
var indent_size := 0
for raw_line in raw_lines:
var spaces := 0
while spaces < raw_line.length() and raw_line[spaces] == " ":
spaces += 1
if spaces > 0 and (indent_size == 0 or spaces < indent_size):
indent_size = spaces
if indent_size > 0:
var space_unit := " ".repeat(indent_size)
for idx in raw_lines.size():
var rl: String = raw_lines[idx]
var tabs := ""
while rl.begins_with(space_unit):
tabs += "\t"
rl = rl.substr(indent_size)
raw_lines[idx] = tabs + rl
# Separate top-level func definitions (place at class level, not inside run())
var class_funcs: PackedStringArray = []
var body_lines: PackedStringArray = []
var i := 0
while i < raw_lines.size():
var line: String = raw_lines[i]
if not line.begins_with("\t") and not line.begins_with(" ") and line.begins_with("func "):
class_funcs.append(line)
i += 1
while i < raw_lines.size():
var next_line: String = raw_lines[i]
if next_line.is_empty() or next_line.begins_with("\t"):
class_funcs.append(next_line)
i += 1
else:
break
else:
body_lines.append(line)
i += 1
var wrapped := """extends Node
var _mcp_output: Array = []
var _mcp_error: String = ""
func _mcp_print(value: Variant) -> void:
_mcp_output.append(str(value))
func _safe_get(node: Node, prop: String, default: Variant = null) -> Variant:
if node == null:
return default
return node.get(prop) if prop in node else default
"""
# Add user's top-level functions at class level
for func_line in class_funcs:
wrapped += func_line + "\n"
if class_funcs.size() > 0:
wrapped += "\n"
wrapped += "func run() -> Variant:\n"
for line in body_lines:
wrapped += "\t" + line + "\n"
wrapped += "\treturn _mcp_output\n"
var script := GDScript.new()
script.source_code = wrapped
var err := script.reload()
if err != OK:
_write_response({"error": "Script compilation failed: %s" % error_string(err)})
return
var temp_node := Node.new()
temp_node.set_script(script)
get_tree().current_scene.add_child(temp_node)
var output: Variant = null
if temp_node.has_method("run"):
output = temp_node.run()
var mcp_output: Array = temp_node.get("_mcp_output") if temp_node.get("_mcp_output") is Array else []
temp_node.queue_free()
_write_response({
"output": mcp_output,
"return_value": str(output) if output != null else null,
})
# ── find_nodes_by_script ──────────────────────────────────────────────────────
func _cmd_find_nodes_by_script(params: Dictionary) -> void:
var script_name: String = params.get("script", "")
if script_name.is_empty():
_write_response({"error": "'script' is required"})
return
var root := get_tree().current_scene
if root == null:
_write_response({"error": "No current scene"})
return
var prop_filter: Array = params.get("properties", [])
var matches: Array = []
_find_nodes_by_script_recursive(root, script_name.to_lower(), prop_filter, matches)
_write_response({"nodes": matches, "count": matches.size()})
func _find_nodes_by_script_recursive(node: Node, script_filter: String, prop_filter: Array, results: Array) -> void:
var script: Script = node.get_script()
if script and script.resource_path.to_lower().contains(script_filter):
var entry := {
"name": node.name,
"path": str(node.get_path()),
"type": node.get_class(),
"script": script.resource_path,
}
var props: Dictionary = {}
if prop_filter.is_empty():
for prop_info in node.get_property_list():
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
if not (usage & PROPERTY_USAGE_EDITOR):
continue
if prop_name.begins_with("_") or prop_name == "script":
continue
props[prop_name] = _serialize_value(node.get(prop_name))
else:
for prop_name: String in prop_filter:
props[prop_name] = _serialize_value(node.get(prop_name))
entry["properties"] = props
results.append(entry)
for child in node.get_children():
_find_nodes_by_script_recursive(child, script_filter, prop_filter, results)
# ── get_autoload ─────────────────────────────────────────────────────────────
func _cmd_get_autoload(params: Dictionary) -> void:
var autoload_name: String = params.get("name", "")
if autoload_name.is_empty():
_write_response({"error": "'name' is required"})
return
var node := get_node_or_null(NodePath("/root/" + autoload_name))
if node == null:
_write_response({"error": "Autoload not found: %s" % autoload_name})
return
var prop_filter: Array = params.get("properties", [])
var props: Dictionary = {}
if prop_filter.is_empty():
for prop_info in node.get_property_list():
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
if not (usage & PROPERTY_USAGE_EDITOR):
continue
if prop_name.begins_with("_") or prop_name == "script":
continue
props[prop_name] = _serialize_value(node.get(prop_name))
else:
for prop_name: String in prop_filter:
props[prop_name] = _serialize_value(node.get(prop_name))
var result := {
"name": autoload_name,
"path": str(node.get_path()),
"type": node.get_class(),
"properties": props,
}
var script: Script = node.get_script()
if script:
result["script"] = script.resource_path
_write_response(result)
# ── batch_get_properties ─────────────────────────────────────────────────────
func _cmd_batch_get_properties(params: Dictionary) -> void:
var nodes: Array = params.get("nodes", [])
if nodes.is_empty():
_write_response({"error": "'nodes' array is required"})
return
var results: Array = []
for entry: Dictionary in nodes:
var node_path: String = entry.get("path", "")
var prop_filter: Array = entry.get("properties", [])
if node_path.is_empty():
results.append({"path": "", "properties": {}, "error": "Empty path"})
continue
var node := get_node_or_null(NodePath(node_path))
if node == null:
results.append({"path": node_path, "properties": {}, "error": "Node not found"})
continue
var props: Dictionary = {}
if prop_filter.is_empty():
for prop_info in node.get_property_list():
var prop_name: String = prop_info["name"]
var usage: int = prop_info["usage"]
if not (usage & PROPERTY_USAGE_EDITOR):
continue
if prop_name.begins_with("_") or prop_name == "script":
continue
props[prop_name] = _serialize_value(node.get(prop_name))
else:
for prop_name: String in prop_filter:
props[prop_name] = _serialize_value(node.get(prop_name))
results.append({"path": node_path, "properties": props})
_write_response({"nodes": results, "count": results.size()})
# ── find_ui_elements ─────────────────────────────────────────────────────────
func _cmd_find_ui_elements(params: Dictionary) -> void:
var root := get_tree().current_scene
if root == null:
_write_response({"error": "No current scene"})
return
var type_filter: String = params.get("type_filter", "")
var elements: Array = []
_find_ui_recursive(root, type_filter, elements)
_write_response({"elements": elements, "count": elements.size()})
func _find_ui_recursive(node: Node, type_filter: String, results: Array) -> void:
if node is Control and node.visible:
var ctrl: Control = node
var entry: Dictionary = {}
var include := false
if ctrl is Button:
var btn: Button = ctrl
entry["type"] = "Button"
entry["text"] = btn.text
entry["disabled"] = btn.disabled
include = true
elif ctrl is Label:
var lbl: Label = ctrl
entry["type"] = "Label"
entry["text"] = lbl.text
include = true
elif ctrl is LineEdit:
var le: LineEdit = ctrl
entry["type"] = "LineEdit"
entry["text"] = le.text
entry["placeholder"] = le.placeholder_text
include = true
elif ctrl is TextEdit:
var te: TextEdit = ctrl
entry["type"] = "TextEdit"
entry["text"] = te.text.left(200)
include = true
elif ctrl is OptionButton:
var ob: OptionButton = ctrl
entry["type"] = "OptionButton"
entry["text"] = ob.text
entry["selected"] = ob.selected
include = true
elif ctrl is CheckBox:
var cb: CheckBox = ctrl
entry["type"] = "CheckBox"
entry["text"] = cb.text
entry["checked"] = cb.button_pressed
include = true
elif ctrl is HSlider or ctrl is VSlider:
var sl: Range = ctrl
entry["type"] = "HSlider" if ctrl is HSlider else "VSlider"
entry["value"] = sl.value
entry["min"] = sl.min_value
entry["max"] = sl.max_value
include = true
if include:
if not type_filter.is_empty() and entry.get("type", "") != type_filter:
pass # Skip non-matching types
else:
var rect := ctrl.get_global_rect()
entry["name"] = str(ctrl.name)
entry["path"] = str(ctrl.get_path())
entry["rect"] = {
"x": rect.position.x,
"y": rect.position.y,
"width": rect.size.x,
"height": rect.size.y,
}
entry["center"] = {
"x": rect.position.x + rect.size.x / 2.0,
"y": rect.position.y + rect.size.y / 2.0,
}
results.append(entry)
for child in node.get_children():
_find_ui_recursive(child, type_filter, results)
# ── click_button_by_text ─────────────────────────────────────────────────────
func _cmd_click_button_by_text(params: Dictionary) -> void:
var text: String = params.get("text", "")
var partial: bool = params.get("partial", true)
if text.is_empty():
_write_response({"error": "'text' is required"})
return
var root := get_tree().current_scene
if root == null:
_write_response({"error": "No current scene"})
return
var btn: Button = _find_button_by_text(root, text, partial)
if btn == null:
_write_response({"error": "No visible button found with text: '%s'" % text})
return
var rect := btn.get_global_rect()
var center := rect.get_center()
var btn_text_value := btn.text
# Capture button path before clicking — the click may trigger a scene
# transition that removes the node from the tree.
var btn_path := str(btn.get_path()) if btn.is_inside_tree() else ""
# Emit the pressed signal directly — more reliable than Input.parse_input_event
# which doesn't always reach GUI Controls.
btn.emit_signal("pressed")
# After the click, the node may have been freed due to scene transition.
# Re-check before accessing any node properties.
if not is_instance_valid(btn) or not btn.is_inside_tree():
_write_response({
"clicked": true,
"button_text": btn_text_value,
"button_path": btn_path,
"position": {"x": center.x, "y": center.y},
"note": "Button was removed from scene tree after click (likely a scene transition)",
})
return
_write_response({
"clicked": true,
"button_text": btn.text,
"button_path": str(btn.get_path()),
"position": {"x": center.x, "y": center.y},
})
func _find_button_by_text(node: Node, text: String, partial: bool) -> Button:
if node is Button and node.visible:
var btn: Button = node
var btn_text := btn.text.to_lower().strip_edges()
var search_text := text.to_lower().strip_edges()
if partial and btn_text.contains(search_text):
return btn
elif not partial and btn_text == search_text:
return btn
for child in node.get_children():
var found := _find_button_by_text(child, text, partial)
if found != null:
return found
return null
# ── wait_for_node ────────────────────────────────────────────────────────────
func _cmd_wait_for_node(params: Dictionary) -> void:
_pending_command = false # Async command — don't trigger crash recovery
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
_write_response({"error": "'node_path' is required"})
return
var timeout_sec: float = params.get("timeout", 5.0)
var poll_interval: int = maxi(int(params.get("poll_frames", 5)), 1)
var attempts := int(timeout_sec / (poll_interval / 60.0))
var frame_counter := 0
for i in attempts:
var node := get_node_or_null(NodePath(node_path))
if node != null:
var result := {
"found": true,
"node_path": str(node.get_path()),
"type": node.get_class(),
"name": str(node.name),
}
var script: Script = node.get_script()
if script:
result["script"] = script.resource_path
_write_response(result)
return
# Wait poll_interval frames
for _f in poll_interval:
await get_tree().process_frame
_write_response({
"found": false,
"node_path": node_path,
"error": "Node not found after %.1fs" % timeout_sec,
})
# ── find_nearby_nodes ─────────────────────────────────────────────────────────
func _cmd_find_nearby_nodes(params: Dictionary) -> void:
var radius: float = float(params.get("radius", 20.0))
var max_results: int = int(params.get("max_results", 10))
var type_filter: String = params.get("type_filter", "")
var group_filter: String = params.get("group_filter", "")
# Resolve origin position
var origin := Vector3.ZERO
var position_param: Variant = params.get("position", null)
if position_param is String:
# node_path — use its global_position
var origin_node := get_node_or_null(NodePath(position_param as String))
if origin_node == null:
_write_response({"error": "Origin node not found: %s" % position_param})
return
if origin_node is Node3D:
origin = (origin_node as Node3D).global_position
elif origin_node is Node2D:
var pos2d: Vector2 = (origin_node as Node2D).global_position
origin = Vector3(pos2d.x, pos2d.y, 0)
else:
_write_response({"error": "Origin node is not Node2D or Node3D: %s" % position_param})
return
elif position_param is Dictionary:
var dict: Dictionary = position_param
origin = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0)))
elif position_param == null:
_write_response({"error": "'position' is required (node_path string or {x,y,z} object)"})
return
var root := get_tree().current_scene
if root == null:
_write_response({"error": "No current scene"})
return
# Collect all spatial nodes within radius
var candidates: Array = []
_find_nearby_recursive(root, origin, radius, type_filter, group_filter, candidates)
# Sort by distance
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["distance"] < b["distance"]
)
# Limit results
if candidates.size() > max_results:
candidates.resize(max_results)
_write_response({
"origin": {"x": origin.x, "y": origin.y, "z": origin.z},
"radius": radius,
"nodes": candidates,
"count": candidates.size(),
})
func _find_nearby_recursive(node: Node, origin: Vector3, radius: float, type_filter: String, group_filter: String, results: Array) -> void:
var pos := Vector3.ZERO
var is_spatial := false
if node is Node3D:
pos = (node as Node3D).global_position
is_spatial = true
elif node is Node2D:
var pos2d: Vector2 = (node as Node2D).global_position
pos = Vector3(pos2d.x, pos2d.y, 0)
is_spatial = true
if is_spatial:
var diff := pos - origin
var dist := diff.length()
if dist <= radius:
# Apply filters
var passes := true
if not type_filter.is_empty() and not node.is_class(type_filter):
passes = false
if not group_filter.is_empty() and not node.is_in_group(group_filter):
passes = false
if passes:
var entry: Dictionary = {
"node_path": str(node.get_path()),
"name": str(node.name),
"type": node.get_class(),
"distance": snappedf(dist, 0.01),
"global_position": {"x": snappedf(pos.x, 0.01), "y": snappedf(pos.y, 0.01), "z": snappedf(pos.z, 0.01)},
"direction": {"x": snappedf(diff.x, 0.01), "y": snappedf(diff.y, 0.01), "z": snappedf(diff.z, 0.01)},
}
var script: Script = node.get_script()
if script:
entry["script"] = script.resource_path
results.append(entry)
for child in node.get_children():
_find_nearby_recursive(child, origin, radius, type_filter, group_filter, results)
# ── navigate_to ──────────────────────────────────────────────────────────────
func _cmd_navigate_to(params: Dictionary) -> void:
# Resolve player position
var player_path: String = params.get("player_path", "/root/Main/Player")
var player := get_node_or_null(NodePath(player_path))
if player == null:
_write_response({"error": "Player not found: %s" % player_path})
return
var player_pos := Vector3.ZERO
if player is Node3D:
player_pos = (player as Node3D).global_position
else:
_write_response({"error": "Player is not Node3D: %s" % player_path})
return
# Resolve target position
var target_param: Variant = params.get("target", null)
var target_pos := Vector3.ZERO
if target_param is String:
var target_node := get_node_or_null(NodePath(target_param as String))
if target_node == null:
_write_response({"error": "Target node not found: %s" % target_param})
return
if target_node is Node3D:
target_pos = (target_node as Node3D).global_position
else:
_write_response({"error": "Target is not Node3D: %s" % target_param})
return
elif target_param is Dictionary:
var dict: Dictionary = target_param
target_pos = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0)))
else:
_write_response({"error": "'target' is required (node_path string or {x,y,z} object)"})
return
# Calculate world direction (XZ plane for 3D movement)
var world_dir := target_pos - player_pos
var distance := world_dir.length()
var flat_dir := Vector3(world_dir.x, 0, world_dir.z).normalized()
# Find camera for relative direction
var camera_path: String = params.get("camera_path", "")
var camera: Camera3D = null
if not camera_path.is_empty():
var cam_node := get_node_or_null(NodePath(camera_path))
if cam_node is Camera3D:
camera = cam_node
else:
# Auto-detect: find Camera3D in scene
camera = get_viewport().get_camera_3d()
var suggested_keys: Array = []
var camera_yaw_delta: float = 0.0
var camera_forward := Vector3.ZERO
if camera != null:
# Camera forward (XZ plane)
camera_forward = -camera.global_basis.z
var cam_flat := Vector3(camera_forward.x, 0, camera_forward.z).normalized()
var cam_right := Vector3(camera_forward.z, 0, -camera_forward.x).normalized()
if flat_dir.length() > 0.01:
# Project target direction onto camera axes
var forward_dot := flat_dir.dot(cam_flat)
var right_dot := flat_dir.dot(cam_right)
# Suggest keys based on dominant direction
if forward_dot > 0.3:
suggested_keys.append("KEY_W")
elif forward_dot < -0.3:
suggested_keys.append("KEY_S")
if right_dot > 0.3:
suggested_keys.append("KEY_D")
elif right_dot < -0.3:
suggested_keys.append("KEY_A")
# Calculate yaw rotation needed to face target directly (W only)
var angle_to_target := atan2(flat_dir.x, flat_dir.z)
var cam_yaw := atan2(cam_flat.x, cam_flat.z)
camera_yaw_delta = angle_to_target - cam_yaw
# Normalize to [-PI, PI]
while camera_yaw_delta > PI:
camera_yaw_delta -= TAU
while camera_yaw_delta < -PI:
camera_yaw_delta += TAU
# Estimate walk duration (rough: assume ~5 units/sec movement speed)
var move_speed: float = float(params.get("move_speed", 5.0))
var estimated_duration := distance / move_speed if move_speed > 0 else 0.0
# Convert yaw delta to approximate mouse relative_x pixels
# Typical: 400px mouse movement ≈ PI radians
var mouse_sensitivity_scale: float = 400.0 / PI
var suggested_mouse_x := -camera_yaw_delta * mouse_sensitivity_scale
_write_response({
"distance": snappedf(distance, 0.01),
"world_direction": {
"x": snappedf(world_dir.x, 0.01),
"y": snappedf(world_dir.y, 0.01),
"z": snappedf(world_dir.z, 0.01),
},
"flat_direction": {
"x": snappedf(flat_dir.x, 0.01),
"z": snappedf(flat_dir.z, 0.01),
},
"suggested_keys": suggested_keys,
"camera_rotation_delta": {
"yaw_radians": snappedf(camera_yaw_delta, 0.001),
"suggested_mouse_relative_x": snappedf(suggested_mouse_x, 1.0),
},
"estimated_duration": snappedf(estimated_duration, 0.1),
"player_position": {"x": snappedf(player_pos.x, 0.01), "y": snappedf(player_pos.y, 0.01), "z": snappedf(player_pos.z, 0.01)},
"target_position": {"x": snappedf(target_pos.x, 0.01), "y": snappedf(target_pos.y, 0.01), "z": snappedf(target_pos.z, 0.01)},
})
# ── move_to ───────────────────────────────────────────────────────────────────
func _cmd_move_to(params: Dictionary) -> void:
# Resolve player node
var player_path: String = params.get("player_path", "/root/Main/Player")
var player := get_node_or_null(NodePath(player_path))
if player == null or not player is Node3D:
_write_response({"error": "Player not found or not Node3D: %s" % player_path})
return
_moveto_player = player as Node3D
# Resolve target position
var target_param: Variant = params.get("target", null)
if target_param is String:
var target_node := get_node_or_null(NodePath(target_param as String))
if target_node == null:
_write_response({"error": "Target node not found: %s" % target_param})
return
if target_node is Node3D:
_moveto_target = (target_node as Node3D).global_position
else:
_write_response({"error": "Target is not Node3D: %s" % target_param})
return
elif target_param is Dictionary:
var dict: Dictionary = target_param
_moveto_target = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0)))
else:
_write_response({"error": "'target' is required (node_path string or {x,y,z} object)"})
return
# Resolve camera pivot
_moveto_camera_pivot = null
var camera_path: String = params.get("camera_path", "")
if not camera_path.is_empty():
var cam_node := get_node_or_null(NodePath(camera_path))
if cam_node is Node3D:
_moveto_camera_pivot = cam_node as Node3D
else:
# Auto-detect: look for SpringArm3D child of player
for child in _moveto_player.get_children():
if child is SpringArm3D:
_moveto_camera_pivot = child as Node3D
break
# Fallback: active Camera3D's parent
if _moveto_camera_pivot == null:
var cam := get_viewport().get_camera_3d()
if cam != null and cam.get_parent() is Node3D and cam.get_parent() != get_tree().root:
_moveto_camera_pivot = cam.get_parent() as Node3D
# Read params
_moveto_arrival_radius = float(params.get("arrival_radius", 1.5))
_moveto_timeout = float(params.get("timeout", 15.0))
_moveto_run = bool(params.get("run", false))
_moveto_look_at = bool(params.get("look_at_target", true))
_moveto_elapsed = 0.0
_moveto_keys_held.clear()
# Check if already at target
var dist := _moveto_player.global_position.distance_to(_moveto_target)
if dist <= _moveto_arrival_radius:
_write_response({
"success": true,
"arrived": true,
"final_distance": snappedf(dist, 0.01),
"final_position": _serialize_value(_moveto_player.global_position),
"target_position": _serialize_value(_moveto_target),
"elapsed_time": 0.0,
})
return
# Start walking — async state, don't trigger crash recovery
_pending_command = false
_state = State.MOVING_TO
# Inject walk key
_inject_key(KEY_W, true)
if _moveto_run:
_inject_key(KEY_SHIFT, true)
func _process_move_to(delta: float) -> void:
# Check for abort (new command arrived)
if FileAccess.file_exists(REQUEST_PATH):
_finish_move_to(false, "Aborted by new command")
_state = State.IDLE
_handle_request()
return
_moveto_elapsed += delta
# Timeout check
if _moveto_elapsed >= _moveto_timeout:
_finish_move_to(false, "Timeout after %.1fs" % _moveto_timeout)
return
# Safety: player freed
if not is_instance_valid(_moveto_player):
_finish_move_to(false, "Player node was freed")
return
var player_pos := _moveto_player.global_position
var flat_target := Vector3(_moveto_target.x, player_pos.y, _moveto_target.z)
var dist := player_pos.distance_to(flat_target)
# Arrival check (XZ distance only, ignore Y)
if dist <= _moveto_arrival_radius:
_finish_move_to(true, "Arrived")
return
# Rotate camera pivot toward target
if _moveto_look_at and _moveto_camera_pivot != null and is_instance_valid(_moveto_camera_pivot):
var dir := flat_target - player_pos
if dir.length_squared() > 0.01:
var target_yaw := atan2(-dir.x, -dir.z)
var current_yaw: float = _moveto_camera_pivot.rotation.y
# Lerp toward target yaw (~10 rad/s)
var yaw_diff := target_yaw - current_yaw
# Normalize to [-PI, PI]
while yaw_diff > PI:
yaw_diff -= TAU
while yaw_diff < -PI:
yaw_diff += TAU
var max_step := 10.0 * delta
var step := clampf(yaw_diff, -max_step, max_step)
_moveto_camera_pivot.rotation.y += step
func _finish_move_to(success: bool, message: String) -> void:
# Release all held keys
_release_all_keys()
_state = State.IDLE
var final_pos := Vector3.ZERO
var final_dist := 0.0
if is_instance_valid(_moveto_player):
final_pos = _moveto_player.global_position
final_dist = final_pos.distance_to(_moveto_target)
_write_response({
"success": success,
"arrived": success,
"message": message,
"final_distance": snappedf(final_dist, 0.01),
"final_position": _serialize_value(final_pos),
"target_position": _serialize_value(_moveto_target),
"elapsed_time": snappedf(_moveto_elapsed, 0.01),
})
func _inject_key(keycode: int, pressed: bool) -> void:
var event := InputEventKey.new()
event.keycode = keycode
event.pressed = pressed
Input.parse_input_event(event)
if pressed:
_moveto_keys_held.append(keycode)
else:
_moveto_keys_held.erase(keycode)
func _release_all_keys() -> void:
for keycode: int in _moveto_keys_held.duplicate():
var event := InputEventKey.new()
event.keycode = keycode
event.pressed = false
Input.parse_input_event(event)
_moveto_keys_held.clear()
# ── Recording ────────────────────────────────────────────────────────────────
func _cmd_start_recording(_params: Dictionary) -> void:
_recording_events.clear()
_recording_start_msec = Time.get_ticks_msec()
_state = State.RECORDING
set_process_input(true)
_write_response({"recording": true, "message": "Recording started"})
func _cmd_stop_recording(_params: Dictionary) -> void:
set_process_input(false)
_state = State.IDLE
var events := _recording_events.duplicate()
var duration_ms := Time.get_ticks_msec() - _recording_start_msec
_write_response({
"recording": false,
"events": events,
"event_count": events.size(),
"duration_ms": duration_ms,
})
func _cmd_replay_recording(params: Dictionary) -> void:
_pending_command = false # Async command — don't trigger crash recovery
var events: Array = params.get("events", [])
if events.is_empty():
_write_response({"error": "No events to replay"})
return
var speed: float = params.get("speed", 1.0)
var start_msec := Time.get_ticks_msec()
for event_data: Dictionary in events:
var delay_ms: int = event_data.get("time_ms", 0)
var adjusted_delay := int(delay_ms / speed)
while Time.get_ticks_msec() - start_msec < adjusted_delay:
await get_tree().process_frame
var event := _reconstruct_event(event_data)
if event != null:
Input.parse_input_event(event)
_write_response({
"replayed": true,
"event_count": events.size(),
"speed": speed,
})
func _input(event: InputEvent) -> void:
if _state != State.RECORDING:
return
var time_ms := Time.get_ticks_msec() - _recording_start_msec
var data: Dictionary = {"time_ms": time_ms}
if event is InputEventKey:
var key: InputEventKey = event
data["type"] = "key"
data["keycode"] = OS.get_keycode_string(key.keycode) if key.keycode != 0 else ""
data["physical_keycode"] = OS.get_keycode_string(key.physical_keycode) if key.physical_keycode != 0 else ""
data["pressed"] = key.pressed
data["shift"] = key.shift_pressed
data["ctrl"] = key.ctrl_pressed
data["alt"] = key.alt_pressed
elif event is InputEventMouseButton:
var mb: InputEventMouseButton = event
data["type"] = "mouse_button"
data["button"] = mb.button_index
data["pressed"] = mb.pressed
data["position"] = {"x": mb.position.x, "y": mb.position.y}
data["double_click"] = mb.double_click
elif event is InputEventMouseMotion:
var mm: InputEventMouseMotion = event
data["type"] = "mouse_motion"
data["position"] = {"x": mm.position.x, "y": mm.position.y}
data["relative"] = {"x": mm.relative.x, "y": mm.relative.y}
elif event is InputEventAction:
var act: InputEventAction = event
data["type"] = "action"
data["action"] = act.action
data["pressed"] = act.pressed
data["strength"] = act.strength
else:
return
_recording_events.append(data)
func _reconstruct_event(data: Dictionary) -> InputEvent:
var type: String = data.get("type", "")
match type:
"key":
var event := InputEventKey.new()
var keycode_str: String = data.get("keycode", "")
if not keycode_str.is_empty():
event.keycode = OS.find_keycode_from_string(keycode_str)
event.pressed = data.get("pressed", true)
event.shift_pressed = data.get("shift", false)
event.ctrl_pressed = data.get("ctrl", false)
event.alt_pressed = data.get("alt", false)
return event
"mouse_button":
var event := InputEventMouseButton.new()
event.button_index = data.get("button", MOUSE_BUTTON_LEFT)
event.pressed = data.get("pressed", true)
event.double_click = data.get("double_click", false)
var pos: Dictionary = data.get("position", {})
event.position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
event.global_position = event.position
return event
"mouse_motion":
var event := InputEventMouseMotion.new()
var pos: Dictionary = data.get("position", {})
event.position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
event.global_position = event.position
var rel: Dictionary = data.get("relative", {})
event.relative = Vector2(rel.get("x", 0.0), rel.get("y", 0.0))
return event
"action":
var event := InputEventAction.new()
event.action = data.get("action", "")
event.pressed = data.get("pressed", true)
event.strength = data.get("strength", 1.0)
return event
return null
# ── Helpers ───────────────────────────────────────────────────────────────────
func _write_response(data: Dictionary) -> void:
_pending_command = false
var json := JSON.stringify(data)
var file := FileAccess.open(RESPONSE_PATH, FileAccess.WRITE)
if file:
file.store_string(json)
file.close()
# ── assert_node_state ─────────────────────────────────────────────────────────
func _cmd_assert_node_state(params: Dictionary) -> void:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
_write_response({"error": "node_path is required"})
return
var property: String = params.get("property", "")
if property.is_empty():
_write_response({"error": "property is required"})
return
var node := get_node_or_null(NodePath(node_path))
if node == null:
_write_response({"error": "Node not found: %s" % node_path})
return
var actual: Variant
if ":" in property:
actual = node.get_indexed(NodePath(property))
else:
actual = node.get(property)
var expected: Variant = params.get("expected", null)
var operator: String = params.get("operator", "eq")
var passed: bool = false
match operator:
"eq":
passed = (str(actual) == str(expected)) or (actual == expected)
"neq":
passed = (actual != expected) and (str(actual) != str(expected))
"gt":
passed = float(actual) > float(expected)
"lt":
passed = float(actual) < float(expected)
"gte":
passed = float(actual) >= float(expected)
"lte":
passed = float(actual) <= float(expected)
"contains":
passed = str(actual).contains(str(expected))
"type_is":
passed = typeof(actual) == int(expected) or type_string(typeof(actual)) == str(expected)
_:
_write_response({"error": "Unknown operator: %s" % operator})
return
_write_response({
"result": {
"assertion": "node_state",
"node_path": node_path,
"property": property,
"operator": operator,
"expected": _serialize_value(expected),
"actual": _serialize_value(actual),
"passed": passed,
}
})
# ── get_performance_monitors ──────────────────────────────────────────────────
func _cmd_get_performance_monitors(_params: Dictionary) -> void:
var monitors := {}
monitors["fps"] = Performance.get_monitor(Performance.TIME_FPS)
monitors["frame_time_msec"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
monitors["physics_frame_time_msec"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
monitors["navigation_process_msec"] = Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0
monitors["memory_static"] = Performance.get_monitor(Performance.MEMORY_STATIC)
monitors["memory_static_max"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX)
monitors["object_count"] = Performance.get_monitor(Performance.OBJECT_COUNT)
monitors["object_resource_count"] = Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT)
monitors["object_node_count"] = Performance.get_monitor(Performance.OBJECT_NODE_COUNT)
monitors["object_orphan_node_count"] = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
monitors["render_total_objects_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)
monitors["render_total_primitives_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)
monitors["render_total_draw_calls_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
monitors["render_video_mem_used"] = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)
monitors["physics_2d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS)
monitors["physics_2d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS)
monitors["physics_2d_island_count"] = Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT)
monitors["physics_3d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS)
monitors["physics_3d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS)
monitors["physics_3d_island_count"] = Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT)
monitors["navigation_active_maps"] = Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS)
monitors["navigation_region_count"] = Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT)
monitors["navigation_agent_count"] = Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT)
_write_response({"result": {"monitors": monitors, "process": "game"}})
func _serialize_value(value: Variant) -> Variant:
if value == null:
return null
match typeof(value):
TYPE_VECTOR2:
var v: Vector2 = value
return {"x": v.x, "y": v.y}
TYPE_VECTOR2I:
var v: Vector2i = value
return {"x": v.x, "y": v.y}
TYPE_VECTOR3:
var v: Vector3 = value
return {"x": v.x, "y": v.y, "z": v.z}
TYPE_VECTOR3I:
var v: Vector3i = value
return {"x": v.x, "y": v.y, "z": v.z}
TYPE_RECT2:
var r: Rect2 = value
return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y}
TYPE_COLOR:
var c: Color = value
return {"r": c.r, "g": c.g, "b": c.b, "a": c.a, "html": "#" + c.to_html()}
TYPE_NODE_PATH:
return str(value)
TYPE_OBJECT:
if value is Resource:
var res: Resource = value
return {"type": res.get_class(), "path": res.resource_path}
return str(value)
TYPE_ARRAY:
var arr: Array = value
var result: Array = []
for item in arr:
result.append(_serialize_value(item))
return result
TYPE_DICTIONARY:
var dict: Dictionary = value
var result: Dictionary = {}
for key in dict:
result[str(key)] = _serialize_value(dict[key])
return result
_:
return value