@beremaran/godot-agent-loop 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +925 -0
- package/addons/godot_agent_loop/LICENSE +23 -0
- package/addons/godot_agent_loop/README.md +31 -0
- package/addons/godot_agent_loop/plugin.cfg +8 -0
- package/addons/godot_agent_loop/plugin.gd +417 -0
- package/agent-plugin/.claude-plugin/plugin.json +19 -0
- package/agent-plugin/.codex-plugin/plugin.json +37 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/adapter-manifest.json +45 -0
- package/agent-plugin/pi/extension.ts +136 -0
- package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
- package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
- package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
- package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
- package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
- package/build/authoring-session-manager.js +237 -0
- package/build/domain-tool-registries.js +207 -0
- package/build/editor-bridge-protocol.js +1 -0
- package/build/editor-connection.js +112 -0
- package/build/editor-mutation-guard.js +30 -0
- package/build/editor-plugin-installer.js +220 -0
- package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
- package/build/game-command-service.js +49 -0
- package/build/game-connection.js +337 -0
- package/build/godot-executable.js +156 -0
- package/build/godot-process-manager.js +112 -0
- package/build/godot-subprocess.js +23 -0
- package/build/headless-operation-runner.js +68 -0
- package/build/headless-operation-service.js +65 -0
- package/build/index.js +478 -0
- package/build/interaction-server-installer.js +234 -0
- package/build/opencode-setup.js +199 -0
- package/build/project-support.js +219 -0
- package/build/runtime-protocol.js +202 -0
- package/build/scripts/godot_operations.gd +1534 -0
- package/build/scripts/mcp_editor_plugin.gd +417 -0
- package/build/scripts/mcp_interaction_server.gd +1255 -0
- package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
- package/build/scripts/mcp_runtime/command_params.gd +339 -0
- package/build/scripts/mcp_runtime/core_domain.gd +591 -0
- package/build/scripts/mcp_runtime/input_domain.gd +695 -0
- package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
- package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
- package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
- package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
- package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
- package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
- package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
- package/build/scripts/mcp_runtime/system_domain.gd +277 -0
- package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
- package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
- package/build/scripts/validate_script.gd +28 -0
- package/build/server-instructions.js +11 -0
- package/build/session-timing.js +20 -0
- package/build/tool-argument-validation.js +120 -0
- package/build/tool-definitions.js +2727 -0
- package/build/tool-handlers/game-tool-handlers.js +1345 -0
- package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
- package/build/tool-handlers/project-handler-services.js +1458 -0
- package/build/tool-handlers/project-tool-handlers.js +1506 -0
- package/build/tool-handlers/visual-regression-service.js +139 -0
- package/build/tool-manifest.js +1193 -0
- package/build/tool-mutation-policy.js +122 -0
- package/build/tool-registry.js +34 -0
- package/build/tool-surface.js +70 -0
- package/build/utils.js +273 -0
- package/package.json +110 -0
- package/product.json +52 -0
- package/scripts/prepare-package.js +42 -0
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
extends Node
|
|
2
|
+
|
|
3
|
+
# MCP Interaction Server - newline-delimited JSON-RPC 2.0 server for game interaction.
|
|
4
|
+
# No class_name to avoid autoload conflict.
|
|
5
|
+
#
|
|
6
|
+
# This script is the composition root: it owns the socket lifecycle, sessions,
|
|
7
|
+
# the request lifecycle, and the command registry. Subsystem handlers live in
|
|
8
|
+
# domain scripts under res://mcp_runtime/, which register their own commands and
|
|
9
|
+
# reach the transport only through RuntimeDomain. Handlers that remain below are
|
|
10
|
+
# cross-cutting runtime commands owned by this composition root.
|
|
11
|
+
|
|
12
|
+
const CommandParams = preload("res://mcp_runtime/command_params.gd")
|
|
13
|
+
const VariantCodec = preload("res://mcp_runtime/variant_codec.gd")
|
|
14
|
+
const PrivilegedCommandPolicy = preload("res://mcp_runtime/privileged_command_policy.gd")
|
|
15
|
+
const DOMAIN_SCRIPTS: Array[String] = [
|
|
16
|
+
"res://mcp_runtime/input_domain.gd",
|
|
17
|
+
"res://mcp_runtime/ui_domain.gd",
|
|
18
|
+
"res://mcp_runtime/scene_2d_domain.gd",
|
|
19
|
+
"res://mcp_runtime/physics_domain.gd",
|
|
20
|
+
"res://mcp_runtime/scene_3d_domain.gd",
|
|
21
|
+
"res://mcp_runtime/rendering_domain.gd",
|
|
22
|
+
"res://mcp_runtime/audio_animation_domain.gd",
|
|
23
|
+
"res://mcp_runtime/networking_domain.gd",
|
|
24
|
+
"res://mcp_runtime/system_domain.gd",
|
|
25
|
+
"res://mcp_runtime/core_domain.gd",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
class RuntimeSession:
|
|
29
|
+
extends RefCounted
|
|
30
|
+
|
|
31
|
+
var id: int
|
|
32
|
+
var peer: StreamPeerTCP
|
|
33
|
+
var buffer: PackedByteArray = PackedByteArray()
|
|
34
|
+
var connected: bool = true
|
|
35
|
+
var request_running: bool = false
|
|
36
|
+
var request_id: Variant = null
|
|
37
|
+
var request_command: String = ""
|
|
38
|
+
var request_state: String = "received"
|
|
39
|
+
var cancellation_requested: bool = false
|
|
40
|
+
var authenticated: bool = false
|
|
41
|
+
var request_correlation_id: String = ""
|
|
42
|
+
var request_started_msec: int = 0
|
|
43
|
+
|
|
44
|
+
func _init(session_id: int, session_peer: StreamPeerTCP) -> void:
|
|
45
|
+
id = session_id
|
|
46
|
+
peer = session_peer
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CommandDescriptor:
|
|
50
|
+
extends RefCounted
|
|
51
|
+
|
|
52
|
+
# Typed dispatch entry for one runtime command. Every handler receives the
|
|
53
|
+
# request params dictionary and is awaited, so synchronous handlers and
|
|
54
|
+
# coroutine handlers share a single execution path in the transport layer.
|
|
55
|
+
var command: String
|
|
56
|
+
var handler: Callable
|
|
57
|
+
var cancellable: bool
|
|
58
|
+
var privileged: bool
|
|
59
|
+
|
|
60
|
+
func _init(command_name: String, command_handler: Callable, is_cancellable: bool, is_privileged: bool) -> void:
|
|
61
|
+
command = command_name
|
|
62
|
+
handler = command_handler
|
|
63
|
+
cancellable = is_cancellable
|
|
64
|
+
privileged = is_privileged
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
var _server: TCPServer
|
|
68
|
+
var _sessions: Dictionary = {}
|
|
69
|
+
var _next_session_id: int = 1
|
|
70
|
+
var _next_correlation_id: int = 1
|
|
71
|
+
# Exactly one runtime command executes at a time. Its session owns the request ID
|
|
72
|
+
# and peer until it responds, or disconnects and its eventual response is discarded.
|
|
73
|
+
var _active_session: RuntimeSession = null
|
|
74
|
+
const DEFAULT_PORT: int = 9090
|
|
75
|
+
const PORT_ENVIRONMENT_VARIABLE: String = "GODOT_MCP_RUNTIME_PORT"
|
|
76
|
+
const SECRET_ENVIRONMENT_VARIABLE: String = "GODOT_MCP_RUNTIME_SECRET"
|
|
77
|
+
const DISABLED_ENVIRONMENT_VARIABLE: String = "GODOT_MCP_RUNTIME_DISABLED"
|
|
78
|
+
# The listen port. Exported for editor configuration; when the game process is
|
|
79
|
+
# started by the MCP server, GODOT_MCP_RUNTIME_PORT overrides it so parallel
|
|
80
|
+
# sessions (and the E2E harness) each get an isolated loopback port.
|
|
81
|
+
@export var port: int = DEFAULT_PORT
|
|
82
|
+
const PROTOCOL_VERSION: String = "1.0"
|
|
83
|
+
const CAPABILITIES: Array[String] = ["runtime-commands", "godot-json-values"]
|
|
84
|
+
const AUTHORING_COMMANDS_CAPABILITY: String = "authoring-commands"
|
|
85
|
+
const RENDERING_CONTEXT_CAPABILITY: String = "rendering-context"
|
|
86
|
+
const METHOD_PREFIX: String = "godot.runtime."
|
|
87
|
+
const CANCELLABLE_COMMANDS: Array[String] = ["wait", "await_signal", "resource", "http_request"]
|
|
88
|
+
const ERROR_LIMIT_EXCEEDED: int = -32006
|
|
89
|
+
const ERROR_PRIVILEGED_COMMAND_DISABLED: int = PrivilegedCommandPolicy.ERROR_CODE
|
|
90
|
+
const ERROR_AUTHENTICATION_REQUIRED: int = -32008
|
|
91
|
+
|
|
92
|
+
# The MCP launcher supplies a fresh secret through the child environment. An
|
|
93
|
+
# empty value retains compatibility for a user-managed runtime, but every
|
|
94
|
+
# MCP-owned launch authenticates before any command is accepted.
|
|
95
|
+
@export var runtime_secret: String = ""
|
|
96
|
+
@export var allow_privileged_commands: bool = false
|
|
97
|
+
|
|
98
|
+
# These limits are exports so a project can tune its local developer runtime
|
|
99
|
+
# without changing the protocol implementation. Values include JSON framing.
|
|
100
|
+
@export var max_request_line_bytes: int = 1 * 1024 * 1024
|
|
101
|
+
@export var max_receive_buffer_bytes: int = 2 * 1024 * 1024
|
|
102
|
+
@export var max_receive_chunk_bytes: int = 64 * 1024
|
|
103
|
+
@export var max_json_nesting_depth: int = 32
|
|
104
|
+
@export var max_json_collection_items: int = 1024
|
|
105
|
+
@export var max_response_bytes: int = 8 * 1024 * 1024
|
|
106
|
+
@export var max_screenshot_pixels: int = 16 * 1024 * 1024
|
|
107
|
+
@export var max_screenshot_png_bytes: int = 6 * 1024 * 1024
|
|
108
|
+
# Command registry: maps a runtime command name to its CommandDescriptor.
|
|
109
|
+
# The transport layer dispatches only through this registry and never names
|
|
110
|
+
# individual subsystem commands.
|
|
111
|
+
var _commands: Dictionary = {}
|
|
112
|
+
# Domain nodes, kept as children so their handlers resolve the scene tree and
|
|
113
|
+
# viewport exactly as the server does. Each owns its subsystem's state.
|
|
114
|
+
var _domains: Array[Node] = []
|
|
115
|
+
var _codec: VariantCodec
|
|
116
|
+
var _privileged_policy: PrivilegedCommandPolicy
|
|
117
|
+
var _profile_active: bool = false
|
|
118
|
+
var _profile_samples: Array[Dictionary] = []
|
|
119
|
+
# Set only by godot_operations.gd when it owns the process main loop. Regular
|
|
120
|
+
# injected game runs publish the command names but do not advertise or execute
|
|
121
|
+
# project-file authoring commands.
|
|
122
|
+
var _authoring_dispatcher: Callable = Callable()
|
|
123
|
+
|
|
124
|
+
func _ready() -> void:
|
|
125
|
+
# Ensure MCP server keeps processing even when game is paused
|
|
126
|
+
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
127
|
+
_codec = VariantCodec.new(max_json_nesting_depth, max_json_collection_items)
|
|
128
|
+
_privileged_policy = PrivilegedCommandPolicy.new()
|
|
129
|
+
runtime_secret = _resolve_runtime_secret()
|
|
130
|
+
_register_domains()
|
|
131
|
+
_register_commands()
|
|
132
|
+
# A one-shot authoring fallback can run while the real game owns this
|
|
133
|
+
# project's runtime port. It still needs the autoloads to parse, but must not
|
|
134
|
+
# start a second transport or emit a misleading bind failure.
|
|
135
|
+
if OS.get_environment(DISABLED_ENVIRONMENT_VARIABLE) == "true":
|
|
136
|
+
return
|
|
137
|
+
_server = TCPServer.new()
|
|
138
|
+
port = _resolve_port()
|
|
139
|
+
var err: int = _server.listen(port, "127.0.0.1")
|
|
140
|
+
if err != OK:
|
|
141
|
+
push_error("McpInteractionServer: Failed to listen on port %d, error: %d" % [port, err])
|
|
142
|
+
return
|
|
143
|
+
print("McpInteractionServer: Listening on 127.0.0.1:%d" % port)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# The environment override wins over the export so the process that launched
|
|
147
|
+
# the game controls where it must connect.
|
|
148
|
+
func _resolve_port() -> int:
|
|
149
|
+
var configured: String = OS.get_environment(PORT_ENVIRONMENT_VARIABLE)
|
|
150
|
+
if configured.is_valid_int():
|
|
151
|
+
var parsed: int = int(configured)
|
|
152
|
+
if parsed > 0 and parsed < 65536:
|
|
153
|
+
return parsed
|
|
154
|
+
push_warning("McpInteractionServer: Ignoring out-of-range %s=%s" % [PORT_ENVIRONMENT_VARIABLE, configured])
|
|
155
|
+
elif not configured.is_empty():
|
|
156
|
+
push_warning("McpInteractionServer: Ignoring non-numeric %s=%s" % [PORT_ENVIRONMENT_VARIABLE, configured])
|
|
157
|
+
return port
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
func _resolve_runtime_secret() -> String:
|
|
161
|
+
var configured: String = OS.get_environment(SECRET_ENVIRONMENT_VARIABLE)
|
|
162
|
+
return configured if not configured.is_empty() else runtime_secret
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
func _process(_delta: float) -> void:
|
|
166
|
+
if _server == null:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Accept all pending connections. A newly connected client must never replace a
|
|
170
|
+
# peer retained by an awaited command from an earlier session.
|
|
171
|
+
while _server.is_connection_available():
|
|
172
|
+
var new_client: StreamPeerTCP = _server.take_connection()
|
|
173
|
+
if new_client != null:
|
|
174
|
+
var session: RuntimeSession = RuntimeSession.new(_next_session_id, new_client)
|
|
175
|
+
_sessions[session.id] = session
|
|
176
|
+
_next_session_id += 1
|
|
177
|
+
print("McpInteractionServer: Client connected (session %d)" % session.id)
|
|
178
|
+
|
|
179
|
+
for session_id: Variant in _sessions.keys():
|
|
180
|
+
var session_value: Variant = _sessions.get(session_id)
|
|
181
|
+
if not session_value is RuntimeSession:
|
|
182
|
+
continue
|
|
183
|
+
var session: RuntimeSession = session_value
|
|
184
|
+
_poll_session(session)
|
|
185
|
+
if not session.connected and session != _active_session:
|
|
186
|
+
@warning_ignore("return_value_discarded")
|
|
187
|
+
_sessions.erase(session.id)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
func _poll_session(session: RuntimeSession) -> void:
|
|
191
|
+
if not session.connected or session.peer == null:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
@warning_ignore("return_value_discarded")
|
|
195
|
+
session.peer.poll()
|
|
196
|
+
var status: int = session.peer.get_status()
|
|
197
|
+
if status == StreamPeerTCP.STATUS_ERROR or status == StreamPeerTCP.STATUS_NONE:
|
|
198
|
+
session.connected = false
|
|
199
|
+
session.buffer = PackedByteArray()
|
|
200
|
+
print("McpInteractionServer: Client disconnected (session %d)" % session.id)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if status != StreamPeerTCP.STATUS_CONNECTED:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
var available: int = session.peer.get_available_bytes()
|
|
207
|
+
if available > 0:
|
|
208
|
+
var remaining_capacity: int = max_receive_buffer_bytes - session.buffer.size()
|
|
209
|
+
if remaining_capacity <= 0:
|
|
210
|
+
_reject_and_close_session(session, "Receive buffer exceeds the configured limit", {"limit_bytes": max_receive_buffer_bytes})
|
|
211
|
+
return
|
|
212
|
+
var bytes_to_read: int = min(available, min(max_receive_chunk_bytes, remaining_capacity))
|
|
213
|
+
var data: Array = session.peer.get_data(bytes_to_read)
|
|
214
|
+
if data[0] == OK:
|
|
215
|
+
var bytes: PackedByteArray = data[1]
|
|
216
|
+
session.buffer.append_array(bytes)
|
|
217
|
+
|
|
218
|
+
# Process complete lines (newline-delimited JSON)
|
|
219
|
+
while true:
|
|
220
|
+
var newline_pos: int = session.buffer.find(10)
|
|
221
|
+
if newline_pos < 0:
|
|
222
|
+
break
|
|
223
|
+
var line_bytes: PackedByteArray = session.buffer.slice(0, newline_pos)
|
|
224
|
+
session.buffer = session.buffer.slice(newline_pos + 1)
|
|
225
|
+
if line_bytes.size() > max_request_line_bytes:
|
|
226
|
+
_reject_and_close_session(session, "Request line exceeds the configured limit", {"limit_bytes": max_request_line_bytes})
|
|
227
|
+
return
|
|
228
|
+
if line_bytes.size() == 0:
|
|
229
|
+
continue
|
|
230
|
+
var line: String = line_bytes.get_string_from_utf8()
|
|
231
|
+
if line.to_utf8_buffer() != line_bytes:
|
|
232
|
+
_reject_and_close_session(session, "Request line is not valid UTF-8")
|
|
233
|
+
return
|
|
234
|
+
line = line.strip_edges()
|
|
235
|
+
if not line.is_empty():
|
|
236
|
+
_handle_command(session, line)
|
|
237
|
+
if not session.connected:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
if session.buffer.size() > max_request_line_bytes:
|
|
241
|
+
_reject_and_close_session(session, "Request line exceeds the configured limit", {"limit_bytes": max_request_line_bytes})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
func _reject_and_close_session(session: RuntimeSession, message: String, details: Dictionary = {}) -> void:
|
|
245
|
+
_send_error(session, null, ERROR_LIMIT_EXCEEDED, message, details)
|
|
246
|
+
session.buffer = PackedByteArray()
|
|
247
|
+
session.connected = false
|
|
248
|
+
if session.peer != null:
|
|
249
|
+
# Drain unread input first: closing a socket with pending received data
|
|
250
|
+
# sends a TCP RST, which would discard the queued error response before
|
|
251
|
+
# the client can read it. Drain is bounded so a flooding peer cannot
|
|
252
|
+
# keep this loop alive.
|
|
253
|
+
var drained: int = 0
|
|
254
|
+
@warning_ignore("return_value_discarded")
|
|
255
|
+
session.peer.poll()
|
|
256
|
+
var pending: int = session.peer.get_available_bytes()
|
|
257
|
+
while pending > 0 and drained < max_receive_buffer_bytes:
|
|
258
|
+
var chunk: int = min(pending, max_receive_chunk_bytes)
|
|
259
|
+
@warning_ignore("return_value_discarded")
|
|
260
|
+
session.peer.get_data(chunk)
|
|
261
|
+
drained += chunk
|
|
262
|
+
@warning_ignore("return_value_discarded")
|
|
263
|
+
session.peer.poll()
|
|
264
|
+
pending = session.peer.get_available_bytes()
|
|
265
|
+
session.peer.disconnect_from_host()
|
|
266
|
+
if session != _active_session:
|
|
267
|
+
@warning_ignore("return_value_discarded")
|
|
268
|
+
_sessions.erase(session.id)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
func _validate_json_limits(value: Variant, depth: int = 0) -> String:
|
|
272
|
+
if depth > max_json_nesting_depth:
|
|
273
|
+
return "JSON nesting exceeds the configured limit of %d" % max_json_nesting_depth
|
|
274
|
+
if value is Array:
|
|
275
|
+
var array_value: Array = value
|
|
276
|
+
if array_value.size() > max_json_collection_items:
|
|
277
|
+
return "JSON array exceeds the configured limit of %d items" % max_json_collection_items
|
|
278
|
+
for item: Variant in array_value:
|
|
279
|
+
var array_error: String = _validate_json_limits(item, depth + 1)
|
|
280
|
+
if not array_error.is_empty():
|
|
281
|
+
return array_error
|
|
282
|
+
elif value is Dictionary:
|
|
283
|
+
var dictionary_value: Dictionary = value
|
|
284
|
+
if dictionary_value.size() > max_json_collection_items:
|
|
285
|
+
return "JSON object exceeds the configured limit of %d properties" % max_json_collection_items
|
|
286
|
+
for key: Variant in dictionary_value:
|
|
287
|
+
var dictionary_error: String = _validate_json_limits(dictionary_value[key], depth + 1)
|
|
288
|
+
if not dictionary_error.is_empty():
|
|
289
|
+
return dictionary_error
|
|
290
|
+
return ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
func _handle_command(session: RuntimeSession, json_str: String) -> void:
|
|
294
|
+
var json: JSON = JSON.new()
|
|
295
|
+
var parse_err: int = json.parse(json_str)
|
|
296
|
+
if parse_err != OK:
|
|
297
|
+
_send_error(session, null, -32700, "Invalid JSON: %s" % json.get_error_message())
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
var data: Variant = json.data
|
|
301
|
+
if not data is Dictionary:
|
|
302
|
+
_send_error(session, null, -32600, "Expected JSON-RPC request object")
|
|
303
|
+
return
|
|
304
|
+
var limits_error: String = _validate_json_limits(data)
|
|
305
|
+
if not limits_error.is_empty():
|
|
306
|
+
_reject_and_close_session(session, limits_error, {"max_depth": max_json_nesting_depth, "max_collection_items": max_json_collection_items})
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
var request: Dictionary = data
|
|
310
|
+
var req_id: Variant = request.get("id", null)
|
|
311
|
+
if request.get("jsonrpc", "") != "2.0" or req_id == null or not request.has("method"):
|
|
312
|
+
_send_error(session, req_id, -32600, "Expected a JSON-RPC 2.0 request with id and method")
|
|
313
|
+
return
|
|
314
|
+
var method: String = str(request.get("method", ""))
|
|
315
|
+
var raw_params: Variant = request.get("params", {})
|
|
316
|
+
if not raw_params is Dictionary:
|
|
317
|
+
_send_error(session, req_id, -32602, "params must be an object")
|
|
318
|
+
return
|
|
319
|
+
var params: Dictionary = raw_params
|
|
320
|
+
if method == "godot.runtime.handshake":
|
|
321
|
+
_handle_handshake(session, req_id, params)
|
|
322
|
+
return
|
|
323
|
+
if not session.authenticated:
|
|
324
|
+
# Never echo the provided secret or any later request parameters.
|
|
325
|
+
_send_error(session, req_id, ERROR_AUTHENTICATION_REQUIRED,
|
|
326
|
+
"Runtime session authentication is required", {"reason": "authentication_required"})
|
|
327
|
+
return
|
|
328
|
+
if not method.begins_with(METHOD_PREFIX):
|
|
329
|
+
_send_error(session, req_id, -32601, "Unknown method: %s" % method)
|
|
330
|
+
return
|
|
331
|
+
if method == "%scancel" % METHOD_PREFIX:
|
|
332
|
+
_handle_cancel(session, req_id, params)
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
var command: String = method.trim_prefix(METHOD_PREFIX)
|
|
336
|
+
var descriptor: CommandDescriptor = _commands.get(command)
|
|
337
|
+
if descriptor == null:
|
|
338
|
+
_send_error(session, req_id, -32601, "Unknown method: %s" % method)
|
|
339
|
+
return
|
|
340
|
+
if descriptor.privileged and not _privileged_policy.is_enabled(command, allow_privileged_commands):
|
|
341
|
+
# Do not include params, source, property values, URLs, headers, or engine
|
|
342
|
+
# error text in this response. The command name and opt-in mechanism are
|
|
343
|
+
# safe policy metadata; request contents remain private to the caller.
|
|
344
|
+
_audit_event("authorization_denied", session, command)
|
|
345
|
+
_send_error(session, req_id, ERROR_PRIVILEGED_COMMAND_DISABLED,
|
|
346
|
+
"Privileged runtime command is disabled by policy",
|
|
347
|
+
_privileged_policy.denial_details(command))
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
if _active_session != null:
|
|
351
|
+
_send_error(session, req_id, -32001, "Server busy processing another command. Try again.")
|
|
352
|
+
return
|
|
353
|
+
session.request_running = true
|
|
354
|
+
session.request_id = req_id
|
|
355
|
+
session.request_command = command
|
|
356
|
+
session.request_state = "running"
|
|
357
|
+
session.cancellation_requested = false
|
|
358
|
+
var supplied_correlation: Variant = params.get("_mcp_correlation_id", "")
|
|
359
|
+
var _correlation_erased: bool = params.erase("_mcp_correlation_id")
|
|
360
|
+
var correlation_string: String = str(supplied_correlation) if supplied_correlation is String else ""
|
|
361
|
+
if correlation_string.length() <= 64 and correlation_string.is_valid_identifier():
|
|
362
|
+
session.request_correlation_id = correlation_string
|
|
363
|
+
else:
|
|
364
|
+
session.request_correlation_id = "runtime_%d_%d" % [session.id, _next_correlation_id]
|
|
365
|
+
_next_correlation_id += 1
|
|
366
|
+
session.request_started_msec = Time.get_ticks_msec()
|
|
367
|
+
_active_session = session
|
|
368
|
+
_audit_request("request_started", session)
|
|
369
|
+
|
|
370
|
+
# Synchronous handlers complete before this await resumes; coroutine
|
|
371
|
+
# handlers suspend here until their own awaits finish.
|
|
372
|
+
await descriptor.handler.call(params)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
func _register_command(command: String, handler: Callable) -> void:
|
|
376
|
+
_commands[command] = CommandDescriptor.new(
|
|
377
|
+
command,
|
|
378
|
+
handler,
|
|
379
|
+
CANCELLABLE_COMMANDS.has(command),
|
|
380
|
+
_privileged_policy.is_privileged(command),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Instantiates each domain, attaches it to the tree, and lets it register its
|
|
385
|
+
# own commands through _register_command. Domains are the unit of the ongoing
|
|
386
|
+
# split; a command belongs either to a domain or to this composition root, and
|
|
387
|
+
# the shared registry rejects a duplicate registration either way.
|
|
388
|
+
func _register_domains() -> void:
|
|
389
|
+
for script_path: String in DOMAIN_SCRIPTS:
|
|
390
|
+
var domain_script: GDScript = load(script_path)
|
|
391
|
+
var domain: Node = domain_script.new()
|
|
392
|
+
domain.name = script_path.get_file().get_basename()
|
|
393
|
+
_domains.append(domain)
|
|
394
|
+
add_child(domain)
|
|
395
|
+
# Domains are loaded by path rather than preloaded, because RuntimeDomain
|
|
396
|
+
# preloads this script to type its transport helpers. These two calls are
|
|
397
|
+
# the one dynamic seam that direction leaves in the composition root.
|
|
398
|
+
@warning_ignore("unsafe_method_access")
|
|
399
|
+
domain.setup(self, _register_command)
|
|
400
|
+
@warning_ignore("unsafe_method_access")
|
|
401
|
+
domain.register_commands()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
func _register_commands() -> void:
|
|
405
|
+
_register_command("authoring_add_node", _cmd_authoring_add_node)
|
|
406
|
+
_register_command("authoring_attach_script", _cmd_authoring_attach_script)
|
|
407
|
+
_register_command("authoring_create_resource", _cmd_authoring_create_resource)
|
|
408
|
+
_register_command("authoring_create_scene", _cmd_authoring_create_scene)
|
|
409
|
+
_register_command("authoring_export_mesh_library", _cmd_authoring_export_mesh_library)
|
|
410
|
+
_register_command("authoring_get_uid", _cmd_authoring_get_uid)
|
|
411
|
+
_register_command("authoring_load_sprite", _cmd_authoring_load_sprite)
|
|
412
|
+
_register_command("authoring_manage_resource", _cmd_authoring_manage_resource)
|
|
413
|
+
_register_command("authoring_manage_scene_signals", _cmd_authoring_manage_scene_signals)
|
|
414
|
+
_register_command("authoring_manage_scene_structure", _cmd_authoring_manage_scene_structure)
|
|
415
|
+
_register_command("authoring_manage_theme_resource", _cmd_authoring_manage_theme_resource)
|
|
416
|
+
_register_command("authoring_modify_node", _cmd_authoring_modify_node)
|
|
417
|
+
_register_command("authoring_read_scene", _cmd_authoring_read_scene)
|
|
418
|
+
_register_command("authoring_remove_node", _cmd_authoring_remove_node)
|
|
419
|
+
_register_command("authoring_resave_resources", _cmd_authoring_resave_resources)
|
|
420
|
+
_register_command("authoring_save_scene", _cmd_authoring_save_scene)
|
|
421
|
+
_register_command("screenshot", _cmd_screenshot)
|
|
422
|
+
_register_command("eval", _cmd_eval)
|
|
423
|
+
_register_command("wait", _cmd_wait)
|
|
424
|
+
_register_command("get_ui_elements", _cmd_get_ui_elements)
|
|
425
|
+
_register_command("pause", _cmd_pause)
|
|
426
|
+
_register_command("get_performance", _cmd_get_performance)
|
|
427
|
+
_register_command("play_animation", _cmd_play_animation)
|
|
428
|
+
_register_command("tween_property", _cmd_tween_property)
|
|
429
|
+
_register_command("create_timer", _cmd_create_timer)
|
|
430
|
+
_register_command("serialize_state", _cmd_serialize_state)
|
|
431
|
+
_register_command("script", _cmd_script)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
func register_authoring_dispatcher(dispatcher: Callable) -> void:
|
|
435
|
+
_authoring_dispatcher = dispatcher
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
func _dispatch_authoring(operation: String, params: Dictionary) -> void:
|
|
439
|
+
if not _authoring_dispatcher.is_valid():
|
|
440
|
+
_send_response({
|
|
441
|
+
"error": "Authoring commands require the harness-owned operations session",
|
|
442
|
+
"error_data": {"reason": "authoring_session_required", "operation": operation},
|
|
443
|
+
})
|
|
444
|
+
return
|
|
445
|
+
var raw_result: Variant = _authoring_dispatcher.call(operation, params)
|
|
446
|
+
if not raw_result is Dictionary:
|
|
447
|
+
_send_response({
|
|
448
|
+
"error": "Authoring dispatcher returned an invalid response",
|
|
449
|
+
"error_data": {"reason": "invalid_authoring_response", "operation": operation},
|
|
450
|
+
})
|
|
451
|
+
return
|
|
452
|
+
var result: Dictionary = raw_result
|
|
453
|
+
_send_response(result)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
func _cmd_authoring_add_node(params: Dictionary) -> void:
|
|
457
|
+
_dispatch_authoring("add_node", params)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
func _cmd_authoring_attach_script(params: Dictionary) -> void:
|
|
461
|
+
_dispatch_authoring("attach_script", params)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
func _cmd_authoring_create_resource(params: Dictionary) -> void:
|
|
465
|
+
_dispatch_authoring("create_resource", params)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
func _cmd_authoring_create_scene(params: Dictionary) -> void:
|
|
469
|
+
_dispatch_authoring("create_scene", params)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
func _cmd_authoring_export_mesh_library(params: Dictionary) -> void:
|
|
473
|
+
_dispatch_authoring("export_mesh_library", params)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
func _cmd_authoring_get_uid(params: Dictionary) -> void:
|
|
477
|
+
_dispatch_authoring("get_uid", params)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
func _cmd_authoring_load_sprite(params: Dictionary) -> void:
|
|
481
|
+
_dispatch_authoring("load_sprite", params)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
func _cmd_authoring_manage_resource(params: Dictionary) -> void:
|
|
485
|
+
_dispatch_authoring("manage_resource", params)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
func _cmd_authoring_manage_scene_signals(params: Dictionary) -> void:
|
|
489
|
+
_dispatch_authoring("manage_scene_signals", params)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
func _cmd_authoring_manage_scene_structure(params: Dictionary) -> void:
|
|
493
|
+
_dispatch_authoring("manage_scene_structure", params)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
func _cmd_authoring_manage_theme_resource(params: Dictionary) -> void:
|
|
497
|
+
_dispatch_authoring("manage_theme_resource", params)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
func _cmd_authoring_modify_node(params: Dictionary) -> void:
|
|
501
|
+
_dispatch_authoring("modify_node", params)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
func _cmd_authoring_read_scene(params: Dictionary) -> void:
|
|
505
|
+
_dispatch_authoring("read_scene", params)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
func _cmd_authoring_remove_node(params: Dictionary) -> void:
|
|
509
|
+
_dispatch_authoring("remove_node", params)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
func _cmd_authoring_resave_resources(params: Dictionary) -> void:
|
|
513
|
+
_dispatch_authoring("resave_resources", params)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
func _cmd_authoring_save_scene(params: Dictionary) -> void:
|
|
517
|
+
_dispatch_authoring("save_scene", params)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
func _handle_handshake(session: RuntimeSession, req_id: Variant, params: Dictionary) -> void:
|
|
521
|
+
if params.get("protocolVersion", "") != PROTOCOL_VERSION:
|
|
522
|
+
_send_error(session, req_id, -32002, "Unsupported protocol version", {"supported": PROTOCOL_VERSION})
|
|
523
|
+
return
|
|
524
|
+
if not session.authenticated and not runtime_secret.is_empty() and params.get("secret", "") != runtime_secret:
|
|
525
|
+
_audit_event("authentication_failed", session)
|
|
526
|
+
_send_error(session, req_id, ERROR_AUTHENTICATION_REQUIRED,
|
|
527
|
+
"Runtime session authentication failed", {"reason": "authentication_failed"})
|
|
528
|
+
return
|
|
529
|
+
session.authenticated = true
|
|
530
|
+
_audit_event("authentication_succeeded", session)
|
|
531
|
+
var capabilities: Array[String] = _privileged_policy.capabilities(CAPABILITIES, allow_privileged_commands)
|
|
532
|
+
if _has_rendering_context():
|
|
533
|
+
capabilities.append(RENDERING_CONTEXT_CAPABILITY)
|
|
534
|
+
if _authoring_dispatcher.is_valid():
|
|
535
|
+
capabilities.append(AUTHORING_COMMANDS_CAPABILITY)
|
|
536
|
+
if not runtime_secret.is_empty():
|
|
537
|
+
capabilities.append("session-authentication")
|
|
538
|
+
_send_response_raw(session, {"jsonrpc": "2.0", "id": req_id, "result": {
|
|
539
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
540
|
+
"capabilities": capabilities,
|
|
541
|
+
}})
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
func _has_rendering_context() -> bool:
|
|
545
|
+
return DisplayServer.get_name() != "headless"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
func _audit_event(event: String, session: RuntimeSession, command: String = "", details: Dictionary = {}) -> void:
|
|
549
|
+
var record: Dictionary = {
|
|
550
|
+
"component": "godot-agent-loop-runtime",
|
|
551
|
+
"event": event,
|
|
552
|
+
"session_id": session.id,
|
|
553
|
+
"unix_time": int(Time.get_unix_time_from_system()),
|
|
554
|
+
}
|
|
555
|
+
if not command.is_empty():
|
|
556
|
+
record["command"] = command
|
|
557
|
+
for key: Variant in details:
|
|
558
|
+
record[key] = details[key]
|
|
559
|
+
print(JSON.stringify(record))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
func _audit_request(event: String, session: RuntimeSession, error_code: int = 0) -> void:
|
|
563
|
+
var details: Dictionary = {
|
|
564
|
+
"correlation_id": session.request_correlation_id,
|
|
565
|
+
"duration_ms": max(0, Time.get_ticks_msec() - session.request_started_msec),
|
|
566
|
+
"state": session.request_state,
|
|
567
|
+
}
|
|
568
|
+
if error_code != 0:
|
|
569
|
+
details["error_code"] = error_code
|
|
570
|
+
_audit_event(event, session, session.request_command, details)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# Cancellation is cooperative: only commands that regularly yield to the scene
|
|
574
|
+
# tree can be cancelled safely. The original request receives -32003 when it
|
|
575
|
+
# observes the cancellation; this acknowledgement is for the cancel request.
|
|
576
|
+
func _handle_cancel(session: RuntimeSession, req_id: Variant, params: Dictionary) -> void:
|
|
577
|
+
var target_id: Variant = params.get("request_id", null)
|
|
578
|
+
if target_id == null:
|
|
579
|
+
_send_error(session, req_id, -32602, "request_id is required")
|
|
580
|
+
return
|
|
581
|
+
if _active_session == null or _active_session != session or not _active_session.request_running or _active_session.request_id != target_id:
|
|
582
|
+
_send_error(session, req_id, -32004, "Request is not running", {"request_id": target_id})
|
|
583
|
+
return
|
|
584
|
+
var descriptor: CommandDescriptor = _commands.get(_active_session.request_command)
|
|
585
|
+
if descriptor == null or not descriptor.cancellable:
|
|
586
|
+
_send_error(session, req_id, -32005, "Request is not cancellable", {"request_id": target_id, "command": _active_session.request_command})
|
|
587
|
+
return
|
|
588
|
+
_active_session.cancellation_requested = true
|
|
589
|
+
_audit_request("cancellation_requested", _active_session)
|
|
590
|
+
_send_response_raw(session, {"jsonrpc": "2.0", "id": req_id, "result": {"cancelled": true, "request_id": target_id}})
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# Send the active request's response only through the session that received it.
|
|
594
|
+
# Disconnected sessions retain their request state until this point, then their
|
|
595
|
+
# response is intentionally discarded rather than sent to a later connection.
|
|
596
|
+
func _send_response(data: Dictionary) -> void:
|
|
597
|
+
var session: RuntimeSession = _active_session
|
|
598
|
+
if session == null or not session.request_running:
|
|
599
|
+
return
|
|
600
|
+
var codec_error: Dictionary = _codec.take_error()
|
|
601
|
+
if not codec_error.is_empty():
|
|
602
|
+
data = {
|
|
603
|
+
"error": codec_error.get("message", "Variant codec failed"),
|
|
604
|
+
"error_data": codec_error,
|
|
605
|
+
}
|
|
606
|
+
_active_session = null
|
|
607
|
+
session.request_running = false
|
|
608
|
+
var id: Variant = session.request_id
|
|
609
|
+
session.request_id = null
|
|
610
|
+
if session.cancellation_requested:
|
|
611
|
+
session.request_state = "cancelled"
|
|
612
|
+
_audit_request("request_cancelled", session, -32003)
|
|
613
|
+
_send_error(session, id, -32003, "Request cancelled", {"command": session.request_command})
|
|
614
|
+
elif data.has("error"):
|
|
615
|
+
session.request_state = "responded"
|
|
616
|
+
_audit_request("request_failed", session, -32000)
|
|
617
|
+
_send_error(session, id, -32000, str(data["error"]), data.get("error_data", null))
|
|
618
|
+
else:
|
|
619
|
+
session.request_state = "responded"
|
|
620
|
+
_audit_request("request_completed", session)
|
|
621
|
+
_send_response_raw(session, {"jsonrpc": "2.0", "id": id, "result": data})
|
|
622
|
+
session.request_command = ""
|
|
623
|
+
session.request_correlation_id = ""
|
|
624
|
+
session.request_started_msec = 0
|
|
625
|
+
session.cancellation_requested = false
|
|
626
|
+
if not session.connected:
|
|
627
|
+
@warning_ignore("return_value_discarded")
|
|
628
|
+
_sessions.erase(session.id)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
func _is_active_request_cancelled() -> bool:
|
|
632
|
+
return _active_session != null and _active_session.cancellation_requested
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
func _send_timeout_response(message: String, details: Dictionary = {}) -> void:
|
|
636
|
+
var session: RuntimeSession = _active_session
|
|
637
|
+
if session == null or not session.request_running:
|
|
638
|
+
return
|
|
639
|
+
_active_session = null
|
|
640
|
+
session.request_running = false
|
|
641
|
+
var id: Variant = session.request_id
|
|
642
|
+
session.request_id = null
|
|
643
|
+
session.request_state = "timed_out"
|
|
644
|
+
_audit_request("request_timed_out", session, -32004)
|
|
645
|
+
session.request_command = ""
|
|
646
|
+
session.request_correlation_id = ""
|
|
647
|
+
session.request_started_msec = 0
|
|
648
|
+
session.cancellation_requested = false
|
|
649
|
+
_send_error(session, id, -32004, message, details)
|
|
650
|
+
if not session.connected:
|
|
651
|
+
@warning_ignore("return_value_discarded")
|
|
652
|
+
_sessions.erase(session.id)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
func _send_limit_response(message: String, details: Dictionary = {}) -> void:
|
|
656
|
+
var session: RuntimeSession = _active_session
|
|
657
|
+
if session == null or not session.request_running:
|
|
658
|
+
return
|
|
659
|
+
_active_session = null
|
|
660
|
+
session.request_running = false
|
|
661
|
+
var id: Variant = session.request_id
|
|
662
|
+
session.request_id = null
|
|
663
|
+
session.request_state = "responded"
|
|
664
|
+
_audit_request("request_failed", session, ERROR_LIMIT_EXCEEDED)
|
|
665
|
+
session.request_command = ""
|
|
666
|
+
session.request_correlation_id = ""
|
|
667
|
+
session.request_started_msec = 0
|
|
668
|
+
session.cancellation_requested = false
|
|
669
|
+
_send_error(session, id, ERROR_LIMIT_EXCEEDED, message, details)
|
|
670
|
+
if not session.connected:
|
|
671
|
+
@warning_ignore("return_value_discarded")
|
|
672
|
+
_sessions.erase(session.id)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# Send response without clearing busy flag (used when rejecting during busy state)
|
|
676
|
+
func _send_response_raw(session: RuntimeSession, data: Dictionary) -> void:
|
|
677
|
+
if not session.connected or session.peer == null:
|
|
678
|
+
return
|
|
679
|
+
var json_str: String = JSON.stringify(data) + "\n"
|
|
680
|
+
var bytes: PackedByteArray = json_str.to_utf8_buffer()
|
|
681
|
+
if bytes.size() > max_response_bytes:
|
|
682
|
+
var fallback: Dictionary = {
|
|
683
|
+
"jsonrpc": "2.0",
|
|
684
|
+
"id": data.get("id", null),
|
|
685
|
+
"error": {
|
|
686
|
+
"code": ERROR_LIMIT_EXCEEDED,
|
|
687
|
+
"message": "Response exceeds the configured limit",
|
|
688
|
+
"data": {"limit_bytes": max_response_bytes},
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
bytes = (JSON.stringify(fallback) + "\n").to_utf8_buffer()
|
|
692
|
+
if bytes.size() > max_response_bytes:
|
|
693
|
+
session.connected = false
|
|
694
|
+
session.peer.disconnect_from_host()
|
|
695
|
+
return
|
|
696
|
+
@warning_ignore("return_value_discarded")
|
|
697
|
+
session.peer.put_data(bytes)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
func _send_error(session: RuntimeSession, id: Variant, code: int, message: String, details: Variant = null) -> void:
|
|
701
|
+
var error: Dictionary = {"code": code, "message": message}
|
|
702
|
+
if details != null:
|
|
703
|
+
error["data"] = details
|
|
704
|
+
_send_response_raw(session, {"jsonrpc": "2.0", "id": id, "error": error})
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# --- Validated parameter helpers ---
|
|
708
|
+
# Handlers build a CommandParams reader, pull typed values, then call
|
|
709
|
+
# _params_invalid() once; on failure it sends the standardized -32000 error
|
|
710
|
+
# with structured details and the handler returns without doing any work.
|
|
711
|
+
func _params_invalid(reader: CommandParams) -> bool:
|
|
712
|
+
if reader.failed():
|
|
713
|
+
_send_params_error(reader)
|
|
714
|
+
return true
|
|
715
|
+
return false
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# The same failure without the question: for a handler that failed the reader on
|
|
719
|
+
# a rule the accessors cannot express and is about to stop.
|
|
720
|
+
func _send_params_error(reader: CommandParams) -> void:
|
|
721
|
+
if not reader.failed():
|
|
722
|
+
return
|
|
723
|
+
_send_response({"error": reader.error_message, "error_data": reader.error_details})
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# Resolves a node parameter relative to the tree root. With a default_path the
|
|
727
|
+
# parameter is optional; either way a missing node records a structured failure.
|
|
728
|
+
func _require_node(reader: CommandParams, param_name: String = "node_path", default_path: String = "") -> Node:
|
|
729
|
+
var path: String
|
|
730
|
+
if default_path.is_empty():
|
|
731
|
+
path = reader.required_node_path(param_name)
|
|
732
|
+
else:
|
|
733
|
+
path = reader.optional_string(param_name, default_path)
|
|
734
|
+
if reader.failed():
|
|
735
|
+
return null
|
|
736
|
+
var node: Node = get_tree().root.get_node_or_null(NodePath(path))
|
|
737
|
+
if node == null:
|
|
738
|
+
reader.fail("Node not found: %s" % path, {"param": name, "reason": "node_not_found", "value": path})
|
|
739
|
+
return node
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# Structured details for command failures caused by a Godot Error value.
|
|
743
|
+
func _godot_error_data(err: int) -> Dictionary:
|
|
744
|
+
return {"reason": "godot_error", "godot_error": err, "godot_error_string": error_string(err)}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# --- Screenshot ---
|
|
748
|
+
func _cmd_screenshot(_params: Dictionary) -> void:
|
|
749
|
+
if not _has_rendering_context():
|
|
750
|
+
_send_response({
|
|
751
|
+
"error": "Screenshot requires a headed Godot session with a reachable rendering context",
|
|
752
|
+
"error_data": {
|
|
753
|
+
"reason": "rendering_context_unavailable",
|
|
754
|
+
"display_driver": DisplayServer.get_name(),
|
|
755
|
+
"remediation": "Run Godot with a desktop display or a virtual display such as Xvfb",
|
|
756
|
+
},
|
|
757
|
+
})
|
|
758
|
+
return
|
|
759
|
+
# Wait one frame so the viewport is fully rendered
|
|
760
|
+
await get_tree().process_frame
|
|
761
|
+
var viewport: Viewport = get_viewport()
|
|
762
|
+
var viewport_size: Vector2i = viewport.get_visible_rect().size
|
|
763
|
+
if viewport_size.x <= 0 or viewport_size.y <= 0 or viewport_size.x * viewport_size.y > max_screenshot_pixels:
|
|
764
|
+
_send_limit_response("Screenshot dimensions exceed the configured limit", {"max_pixels": max_screenshot_pixels})
|
|
765
|
+
return
|
|
766
|
+
var image: Image = viewport.get_texture().get_image()
|
|
767
|
+
if image == null:
|
|
768
|
+
_send_response({"error": "Failed to capture screenshot"})
|
|
769
|
+
return
|
|
770
|
+
var png_buffer: PackedByteArray = image.save_png_to_buffer()
|
|
771
|
+
if png_buffer.size() > max_screenshot_png_bytes:
|
|
772
|
+
_send_limit_response("Screenshot payload exceeds the configured limit", {"limit_bytes": max_screenshot_png_bytes})
|
|
773
|
+
return
|
|
774
|
+
var base64_str: String = Marshalls.raw_to_base64(png_buffer)
|
|
775
|
+
_send_response({
|
|
776
|
+
"success": true,
|
|
777
|
+
"data": base64_str,
|
|
778
|
+
"width": image.get_width(),
|
|
779
|
+
"height": image.get_height()
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
# --- Get UI Elements ---
|
|
784
|
+
func _cmd_get_ui_elements(_params: Dictionary) -> void:
|
|
785
|
+
var elements: Array = []
|
|
786
|
+
_collect_ui_elements(get_tree().root, elements)
|
|
787
|
+
_send_response({"success": true, "elements": elements})
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
func _collect_ui_elements(node: Node, elements: Array) -> void:
|
|
791
|
+
if node is Control:
|
|
792
|
+
var ctrl: Control = node as Control
|
|
793
|
+
if ctrl.visible and ctrl.get_global_rect().size.x > 0:
|
|
794
|
+
var info: Dictionary = {
|
|
795
|
+
"name": ctrl.name,
|
|
796
|
+
"type": ctrl.get_class(),
|
|
797
|
+
"path": str(ctrl.get_path()),
|
|
798
|
+
"position": {"x": ctrl.global_position.x, "y": ctrl.global_position.y},
|
|
799
|
+
"size": {"width": ctrl.size.x, "height": ctrl.size.y},
|
|
800
|
+
}
|
|
801
|
+
# Get text content for common text-bearing nodes
|
|
802
|
+
if ctrl is Label:
|
|
803
|
+
info["text"] = (ctrl as Label).text
|
|
804
|
+
elif ctrl is Button:
|
|
805
|
+
info["text"] = (ctrl as Button).text
|
|
806
|
+
elif ctrl is LineEdit:
|
|
807
|
+
info["text"] = (ctrl as LineEdit).text
|
|
808
|
+
elif ctrl is RichTextLabel:
|
|
809
|
+
info["text"] = (ctrl as RichTextLabel).get_parsed_text()
|
|
810
|
+
|
|
811
|
+
elements.append(info)
|
|
812
|
+
|
|
813
|
+
for child in node.get_children():
|
|
814
|
+
_collect_ui_elements(child, elements)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# --- Get Scene Tree ---
|
|
818
|
+
func _cmd_eval(params: Dictionary) -> void:
|
|
819
|
+
var code: String = params.get("code", "")
|
|
820
|
+
if code.is_empty():
|
|
821
|
+
_send_response({"error": "No code provided"})
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
# Wrap user code in a function so we can capture the return value
|
|
825
|
+
var script_source: String = """extends Node
|
|
826
|
+
|
|
827
|
+
func execute():
|
|
828
|
+
var __result = null
|
|
829
|
+
__result = await _run()
|
|
830
|
+
return __result
|
|
831
|
+
|
|
832
|
+
func _run():
|
|
833
|
+
%s
|
|
834
|
+
""" % [_indent_code(code)]
|
|
835
|
+
|
|
836
|
+
var script: GDScript = GDScript.new()
|
|
837
|
+
script.source_code = script_source
|
|
838
|
+
var err: int = script.reload()
|
|
839
|
+
if err != OK:
|
|
840
|
+
_send_response({"error": "Failed to compile GDScript (error %d). Check syntax." % err})
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
var temp_node: Node = Node.new()
|
|
844
|
+
temp_node.set_script(script)
|
|
845
|
+
# Allow eval to work even when game is paused
|
|
846
|
+
temp_node.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
847
|
+
add_child(temp_node)
|
|
848
|
+
|
|
849
|
+
var result: Variant = null
|
|
850
|
+
if temp_node.has_method("execute"):
|
|
851
|
+
# The eval source is compiled at request time, so this call is dynamic by
|
|
852
|
+
# definition. It is gated by the privileged-command policy.
|
|
853
|
+
@warning_ignore("unsafe_method_access")
|
|
854
|
+
result = await temp_node.execute()
|
|
855
|
+
|
|
856
|
+
temp_node.queue_free()
|
|
857
|
+
_send_response({"success": true, "result": _variant_to_json(result)})
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
func _indent_code(code: String) -> String:
|
|
861
|
+
var lines: PackedStringArray = code.split("\n")
|
|
862
|
+
var indented: String = ""
|
|
863
|
+
for line in lines:
|
|
864
|
+
indented += "\t" + line + "\n"
|
|
865
|
+
return indented
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# --- Get Property ---
|
|
869
|
+
func _cmd_pause(params: Dictionary) -> void:
|
|
870
|
+
var paused: bool = params.get("paused", true)
|
|
871
|
+
get_tree().paused = paused
|
|
872
|
+
_send_response({"success": true, "paused": paused})
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# --- Get Performance ---
|
|
876
|
+
func _cmd_get_performance(params: Dictionary) -> void:
|
|
877
|
+
var action: String = str(params.get("action", "sample"))
|
|
878
|
+
if not ["sample", "start", "stop", "report", "leaks"].has(action):
|
|
879
|
+
_send_response({"error": "action must be sample, start, stop, report, or leaks"})
|
|
880
|
+
return
|
|
881
|
+
if action == "start":
|
|
882
|
+
_profile_active = true
|
|
883
|
+
_profile_samples.clear()
|
|
884
|
+
_send_response({"success": true, "profiling": true, "sample_count": 0})
|
|
885
|
+
return
|
|
886
|
+
if action == "stop":
|
|
887
|
+
_profile_active = false
|
|
888
|
+
var stopped: Dictionary = _profile_report()
|
|
889
|
+
stopped["profiling"] = false
|
|
890
|
+
_send_response(stopped)
|
|
891
|
+
return
|
|
892
|
+
if action == "report":
|
|
893
|
+
var report: Dictionary = _profile_report()
|
|
894
|
+
report["profiling"] = _profile_active
|
|
895
|
+
_send_response(report)
|
|
896
|
+
return
|
|
897
|
+
var sample_count: int = CommandParams.to_int(params.get("sample_count", params.get("sampleCount", 1)), 1)
|
|
898
|
+
if sample_count < 1 or sample_count > 120:
|
|
899
|
+
_send_response({"error": "sample_count must be between 1 and 120", "error_data": {"param": "sample_count", "reason": "out_of_range"}})
|
|
900
|
+
return
|
|
901
|
+
var samples: Array[Dictionary] = []
|
|
902
|
+
for index: int in range(sample_count):
|
|
903
|
+
var current: Dictionary = _performance_snapshot()
|
|
904
|
+
samples.append(current)
|
|
905
|
+
if _profile_active:
|
|
906
|
+
_profile_samples.append(current)
|
|
907
|
+
if index + 1 < sample_count:
|
|
908
|
+
await get_tree().process_frame
|
|
909
|
+
var result: Dictionary = samples[0].duplicate(true)
|
|
910
|
+
result["success"] = true
|
|
911
|
+
result["samples"] = samples
|
|
912
|
+
result["profiling"] = _profile_active
|
|
913
|
+
result["requested_sample_count"] = sample_count
|
|
914
|
+
if action == "leaks":
|
|
915
|
+
result["leak_diagnostics"] = {
|
|
916
|
+
"object_count": result.get("object_count", 0),
|
|
917
|
+
"object_node_count": result.get("object_node_count", 0),
|
|
918
|
+
"object_orphan_node_count": result.get("object_orphan_node_count", 0),
|
|
919
|
+
"static_memory_bytes": result.get("memory_static", 0),
|
|
920
|
+
"tracked_profile_samples": _profile_samples.size(),
|
|
921
|
+
}
|
|
922
|
+
_send_response(result)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
func _performance_snapshot() -> Dictionary:
|
|
926
|
+
return {
|
|
927
|
+
"fps": Performance.get_monitor(Performance.TIME_FPS),
|
|
928
|
+
"frame_time": Performance.get_monitor(Performance.TIME_PROCESS),
|
|
929
|
+
"physics_frame_time": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS),
|
|
930
|
+
"memory_static": Performance.get_monitor(Performance.MEMORY_STATIC),
|
|
931
|
+
"memory_static_max": Performance.get_monitor(Performance.MEMORY_STATIC_MAX),
|
|
932
|
+
"object_count": Performance.get_monitor(Performance.OBJECT_COUNT),
|
|
933
|
+
"object_node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
|
|
934
|
+
"object_orphan_node_count": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
|
|
935
|
+
"render_total_objects": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
|
|
936
|
+
"render_total_draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
func _profile_report() -> Dictionary:
|
|
941
|
+
var report: Dictionary = {"success": true, "sample_count": _profile_samples.size(), "samples": _profile_samples.duplicate(true)}
|
|
942
|
+
if _profile_samples.is_empty():
|
|
943
|
+
report["summary"] = {}
|
|
944
|
+
return report
|
|
945
|
+
var latest: Dictionary = _profile_samples[_profile_samples.size() - 1]
|
|
946
|
+
var fps_total: float = 0.0
|
|
947
|
+
var frame_total: float = 0.0
|
|
948
|
+
for sample: Dictionary in _profile_samples:
|
|
949
|
+
fps_total += _metric_float(sample.get("fps", 0.0))
|
|
950
|
+
frame_total += _metric_float(sample.get("frame_time", 0.0))
|
|
951
|
+
report["summary"] = {
|
|
952
|
+
"fps_average": fps_total / _profile_samples.size(),
|
|
953
|
+
"frame_time_average": frame_total / _profile_samples.size(),
|
|
954
|
+
"latest_object_count": latest.get("object_count", 0),
|
|
955
|
+
"latest_orphan_node_count": latest.get("object_orphan_node_count", 0),
|
|
956
|
+
}
|
|
957
|
+
return report
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
func _metric_float(value: Variant) -> float:
|
|
961
|
+
if value is int or value is float:
|
|
962
|
+
return value
|
|
963
|
+
return 0.0
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
# --- Wait N Frames ---
|
|
967
|
+
func _cmd_wait(params: Dictionary) -> void:
|
|
968
|
+
var reader := CommandParams.new(params)
|
|
969
|
+
var frames: int = reader.optional_int("frames", 1, 1)
|
|
970
|
+
var frame_type: String = reader.optional_enum("frame_type", "render", ["render", "physics"])
|
|
971
|
+
if _params_invalid(reader):
|
|
972
|
+
return
|
|
973
|
+
var use_physics: bool = frame_type == "physics" or CommandParams.to_bool(params.get("physics"), false)
|
|
974
|
+
for i: int in frames:
|
|
975
|
+
if use_physics:
|
|
976
|
+
await get_tree().physics_frame
|
|
977
|
+
else:
|
|
978
|
+
await get_tree().process_frame
|
|
979
|
+
if _active_session != null and _active_session.cancellation_requested:
|
|
980
|
+
_send_response({})
|
|
981
|
+
return
|
|
982
|
+
_send_response({"success": true, "waited_frames": frames, "frame_type": "physics" if use_physics else "render"})
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# --- Shared JSON/Variant codec boundary ---
|
|
986
|
+
func _variant_to_json(value: Variant) -> Variant:
|
|
987
|
+
_codec.configure(max_json_nesting_depth, max_json_collection_items)
|
|
988
|
+
return _codec.encode(value)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
func _json_to_variant(value: Variant, type_hint: String = "") -> Variant:
|
|
992
|
+
_codec.configure(max_json_nesting_depth, max_json_collection_items)
|
|
993
|
+
return _codec.decode(value, type_hint)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
func _json_to_variant_for_property(node: Node, property: String, value: Variant) -> Variant:
|
|
997
|
+
_codec.configure(max_json_nesting_depth, max_json_collection_items)
|
|
998
|
+
return _codec.decode_for_property(node, property, value)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
# --- Connect Signal ---
|
|
1002
|
+
func _cmd_play_animation(params: Dictionary) -> void:
|
|
1003
|
+
var node_path: String = params.get("node_path", "")
|
|
1004
|
+
if node_path.is_empty():
|
|
1005
|
+
_send_response({"error": "node_path is required"})
|
|
1006
|
+
return
|
|
1007
|
+
|
|
1008
|
+
var node: Node = get_tree().root.get_node_or_null(node_path)
|
|
1009
|
+
if node == null:
|
|
1010
|
+
_send_response({"error": "Node not found: %s" % node_path})
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
if not node is AnimationPlayer:
|
|
1014
|
+
_send_response({"error": "Node is not an AnimationPlayer: %s (is %s)" % [node_path, node.get_class()]})
|
|
1015
|
+
return
|
|
1016
|
+
|
|
1017
|
+
var anim_player: AnimationPlayer = node as AnimationPlayer
|
|
1018
|
+
var action: String = params.get("action", "play")
|
|
1019
|
+
|
|
1020
|
+
match action:
|
|
1021
|
+
"play":
|
|
1022
|
+
var animation: String = params.get("animation", "")
|
|
1023
|
+
if animation.is_empty():
|
|
1024
|
+
_send_response({"error": "animation name is required for play action"})
|
|
1025
|
+
return
|
|
1026
|
+
if not anim_player.has_animation(animation):
|
|
1027
|
+
_send_response({"error": "Animation '%s' not found. Available: %s" % [animation, str(anim_player.get_animation_list())]})
|
|
1028
|
+
return
|
|
1029
|
+
anim_player.play(animation)
|
|
1030
|
+
_send_response({"success": true, "action": "play", "animation": animation})
|
|
1031
|
+
"stop":
|
|
1032
|
+
anim_player.stop()
|
|
1033
|
+
_send_response({"success": true, "action": "stop"})
|
|
1034
|
+
"pause":
|
|
1035
|
+
anim_player.pause()
|
|
1036
|
+
_send_response({"success": true, "action": "pause"})
|
|
1037
|
+
"get_list":
|
|
1038
|
+
var anims: Array = []
|
|
1039
|
+
for anim_name in anim_player.get_animation_list():
|
|
1040
|
+
anims.append(str(anim_name))
|
|
1041
|
+
_send_response({"success": true, "animations": anims, "current": anim_player.current_animation, "playing": anim_player.is_playing()})
|
|
1042
|
+
_:
|
|
1043
|
+
_send_response({"error": "Unknown animation action: %s. Use play, stop, pause, or get_list" % action})
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
# --- Tween Property ---
|
|
1047
|
+
func _cmd_tween_property(params: Dictionary) -> void:
|
|
1048
|
+
var node_path: String = params.get("node_path", "")
|
|
1049
|
+
var property: String = params.get("property", "")
|
|
1050
|
+
if node_path.is_empty() or property.is_empty():
|
|
1051
|
+
_send_response({"error": "node_path and property are required"})
|
|
1052
|
+
return
|
|
1053
|
+
|
|
1054
|
+
var node: Node = get_tree().root.get_node_or_null(node_path)
|
|
1055
|
+
if node == null:
|
|
1056
|
+
_send_response({"error": "Node not found: %s" % node_path})
|
|
1057
|
+
return
|
|
1058
|
+
|
|
1059
|
+
var final_value: Variant = _json_to_variant_for_property(node, property, params.get("final_value", null))
|
|
1060
|
+
var duration: float = CommandParams.to_float(params.get("duration"), 1.0)
|
|
1061
|
+
var trans_type: int = CommandParams.to_int(params.get("trans_type"), 0) # Tween.TRANS_LINEAR
|
|
1062
|
+
var ease_type: int = CommandParams.to_int(params.get("ease_type"), 2) # Tween.EASE_IN_OUT
|
|
1063
|
+
|
|
1064
|
+
var tween: Tween = create_tween()
|
|
1065
|
+
var tweener: PropertyTweener = tween.tween_property(node, property, final_value, duration)
|
|
1066
|
+
if tweener == null:
|
|
1067
|
+
tween.kill()
|
|
1068
|
+
_send_response({"error": "tween_property failed: value type does not match property '%s' on %s" % [property, node.get_class()]})
|
|
1069
|
+
return
|
|
1070
|
+
@warning_ignore("return_value_discarded")
|
|
1071
|
+
tweener.set_trans(trans_type).set_ease(ease_type)
|
|
1072
|
+
_send_response({"success": true, "node": node_path, "property": property, "duration": duration})
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
# --- Get Nodes In Group ---
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
func _cmd_create_timer(params: Dictionary) -> void:
|
|
1079
|
+
var parent_path: String = params.get("parent_path", "/root")
|
|
1080
|
+
var wait_time: float = CommandParams.to_float(params.get("wait_time"), 1.0)
|
|
1081
|
+
var one_shot: bool = params.get("one_shot", false)
|
|
1082
|
+
var autostart: bool = params.get("autostart", false)
|
|
1083
|
+
|
|
1084
|
+
var parent: Node = get_tree().root.get_node_or_null(parent_path)
|
|
1085
|
+
if parent == null:
|
|
1086
|
+
_send_response({"error": "Parent node not found: %s" % parent_path})
|
|
1087
|
+
return
|
|
1088
|
+
|
|
1089
|
+
var timer: Timer = Timer.new()
|
|
1090
|
+
timer.wait_time = wait_time
|
|
1091
|
+
timer.one_shot = one_shot
|
|
1092
|
+
timer.autostart = autostart
|
|
1093
|
+
var timer_name: String = CommandParams.json_string(params, "name")
|
|
1094
|
+
if not timer_name.is_empty():
|
|
1095
|
+
timer.name = timer_name
|
|
1096
|
+
parent.add_child(timer)
|
|
1097
|
+
if autostart:
|
|
1098
|
+
timer.start()
|
|
1099
|
+
_send_response({"success": true, "path": str(timer.get_path()), "name": timer.name, "wait_time": timer.wait_time, "one_shot": timer.one_shot, "autostart": autostart})
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
# --- Set Particles ---
|
|
1103
|
+
func _cmd_serialize_state(params: Dictionary) -> void:
|
|
1104
|
+
var node_path: String = params.get("node_path", "/root")
|
|
1105
|
+
var action: String = params.get("action", "save")
|
|
1106
|
+
var max_depth: int = CommandParams.to_int(params.get("max_depth"), 5)
|
|
1107
|
+
|
|
1108
|
+
var node: Node = get_tree().root.get_node_or_null(node_path)
|
|
1109
|
+
if node == null:
|
|
1110
|
+
_send_response({"error": "Node not found: %s" % node_path})
|
|
1111
|
+
return
|
|
1112
|
+
|
|
1113
|
+
match action:
|
|
1114
|
+
"save":
|
|
1115
|
+
var state: Dictionary = _serialize_node(node, max_depth, 0)
|
|
1116
|
+
_send_response({"success": true, "action": "save", "state": state})
|
|
1117
|
+
"load":
|
|
1118
|
+
var data: Dictionary = params.get("data", {})
|
|
1119
|
+
if data.is_empty():
|
|
1120
|
+
_send_response({"error": "data is required for load action"})
|
|
1121
|
+
return
|
|
1122
|
+
var count: int = _deserialize_node(node, data)
|
|
1123
|
+
_send_response({"success": true, "action": "load", "restored_count": count})
|
|
1124
|
+
_:
|
|
1125
|
+
_send_response({"error": "Unknown serialize action: %s. Use save or load" % action})
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
func _serialize_node(node: Node, max_depth: int, depth: int) -> Dictionary:
|
|
1129
|
+
var result: Dictionary = {
|
|
1130
|
+
"class": node.get_class(),
|
|
1131
|
+
"name": node.name,
|
|
1132
|
+
"path": str(node.get_path()),
|
|
1133
|
+
}
|
|
1134
|
+
# Capture editor-visible properties
|
|
1135
|
+
var props: Dictionary = {}
|
|
1136
|
+
for prop in node.get_property_list():
|
|
1137
|
+
var prop_dict: Dictionary = prop
|
|
1138
|
+
if prop_dict.get("usage", 0) & PROPERTY_USAGE_STORAGE:
|
|
1139
|
+
var prop_name: String = prop_dict.get("name", "")
|
|
1140
|
+
if prop_name.is_empty() or prop_name.begins_with("_"):
|
|
1141
|
+
continue
|
|
1142
|
+
props[prop_name] = _variant_to_json(node.get(prop_name))
|
|
1143
|
+
result["properties"] = props
|
|
1144
|
+
|
|
1145
|
+
if depth < max_depth:
|
|
1146
|
+
var children: Array = []
|
|
1147
|
+
for child in node.get_children():
|
|
1148
|
+
# Skip the MCP interaction server itself
|
|
1149
|
+
if child == self:
|
|
1150
|
+
continue
|
|
1151
|
+
children.append(_serialize_node(child, max_depth, depth + 1))
|
|
1152
|
+
result["children"] = children
|
|
1153
|
+
|
|
1154
|
+
return result
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
func _deserialize_node(node: Node, data: Dictionary) -> int:
|
|
1158
|
+
var count: int = 0
|
|
1159
|
+
# Restore properties
|
|
1160
|
+
var props: Dictionary = CommandParams.json_dictionary(data, "properties")
|
|
1161
|
+
for prop_name: Variant in props:
|
|
1162
|
+
var property: String = str(prop_name)
|
|
1163
|
+
var value: Variant = _json_to_variant_for_property(node, property, props[prop_name])
|
|
1164
|
+
node.set(property, value)
|
|
1165
|
+
count += 1
|
|
1166
|
+
|
|
1167
|
+
# Restore children
|
|
1168
|
+
var children_data: Array = CommandParams.json_array(data, "children")
|
|
1169
|
+
for child_data: Variant in children_data:
|
|
1170
|
+
if not child_data is Dictionary:
|
|
1171
|
+
continue
|
|
1172
|
+
var child_state: Dictionary = child_data
|
|
1173
|
+
var child_name: String = CommandParams.json_string(child_state, "name")
|
|
1174
|
+
var child: Node = null
|
|
1175
|
+
for c: Node in node.get_children():
|
|
1176
|
+
if c.name == child_name:
|
|
1177
|
+
child = c
|
|
1178
|
+
break
|
|
1179
|
+
if child != null:
|
|
1180
|
+
count += _deserialize_node(child, child_state)
|
|
1181
|
+
return count
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
# --- Bone Pose ---
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
func _cmd_script(params: Dictionary) -> void:
|
|
1189
|
+
var node_path: String = params.get("node_path", "")
|
|
1190
|
+
var node: Node = get_tree().root.get_node_or_null(node_path)
|
|
1191
|
+
if node == null:
|
|
1192
|
+
_send_response({"error": "Node not found: %s" % node_path})
|
|
1193
|
+
return
|
|
1194
|
+
var action: String = params.get("action", "get_source")
|
|
1195
|
+
match action:
|
|
1196
|
+
"get_source":
|
|
1197
|
+
var attached: Variant = node.get_script()
|
|
1198
|
+
if attached == null:
|
|
1199
|
+
_send_response({"success": true, "has_script": false})
|
|
1200
|
+
return
|
|
1201
|
+
var script_resource: Script = attached
|
|
1202
|
+
var source_code: String = ""
|
|
1203
|
+
if script_resource is GDScript:
|
|
1204
|
+
var gdscript: GDScript = script_resource
|
|
1205
|
+
source_code = gdscript.source_code
|
|
1206
|
+
_send_response({"success": true, "has_script": true, "source": source_code, "path": script_resource.resource_path})
|
|
1207
|
+
"attach":
|
|
1208
|
+
var source: String = params.get("source", "")
|
|
1209
|
+
if source.is_empty():
|
|
1210
|
+
_send_response({"error": "source is required for attach"})
|
|
1211
|
+
return
|
|
1212
|
+
var s: GDScript = GDScript.new()
|
|
1213
|
+
s.source_code = source
|
|
1214
|
+
var err: int = s.reload()
|
|
1215
|
+
if err != OK:
|
|
1216
|
+
_send_response({"error": "Script compile error: %d" % err})
|
|
1217
|
+
return
|
|
1218
|
+
node.set_script(s)
|
|
1219
|
+
_send_response({"success": true, "action": "attach", "node_path": node_path})
|
|
1220
|
+
"detach":
|
|
1221
|
+
node.set_script(null)
|
|
1222
|
+
_send_response({"success": true, "action": "detach", "node_path": node_path})
|
|
1223
|
+
_:
|
|
1224
|
+
_send_response({"error": "Unknown script action: %s" % action})
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
# ==========================================================================
|
|
1229
|
+
# Batch 2: 3D Rendering + Lighting + Sky + Physics
|
|
1230
|
+
# ==========================================================================
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
func _exit_tree() -> void:
|
|
1244
|
+
if _active_session != null and _active_session.request_running:
|
|
1245
|
+
_active_session.request_state = "abandoned"
|
|
1246
|
+
_audit_request("request_abandoned", _active_session)
|
|
1247
|
+
for session: RuntimeSession in _sessions.values():
|
|
1248
|
+
if session.peer != null:
|
|
1249
|
+
session.peer.disconnect_from_host()
|
|
1250
|
+
_sessions.clear()
|
|
1251
|
+
_active_session = null
|
|
1252
|
+
if _server != null:
|
|
1253
|
+
_server.stop()
|
|
1254
|
+
_server = null
|
|
1255
|
+
print("McpInteractionServer: Stopped")
|