@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.
Files changed (70) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +925 -0
  3. package/addons/godot_agent_loop/LICENSE +23 -0
  4. package/addons/godot_agent_loop/README.md +31 -0
  5. package/addons/godot_agent_loop/plugin.cfg +8 -0
  6. package/addons/godot_agent_loop/plugin.gd +417 -0
  7. package/agent-plugin/.claude-plugin/plugin.json +19 -0
  8. package/agent-plugin/.codex-plugin/plugin.json +37 -0
  9. package/agent-plugin/.mcp.json +14 -0
  10. package/agent-plugin/adapter-manifest.json +45 -0
  11. package/agent-plugin/pi/extension.ts +136 -0
  12. package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
  13. package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
  14. package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
  15. package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
  16. package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
  17. package/build/authoring-session-manager.js +237 -0
  18. package/build/domain-tool-registries.js +207 -0
  19. package/build/editor-bridge-protocol.js +1 -0
  20. package/build/editor-connection.js +112 -0
  21. package/build/editor-mutation-guard.js +30 -0
  22. package/build/editor-plugin-installer.js +220 -0
  23. package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
  24. package/build/game-command-service.js +49 -0
  25. package/build/game-connection.js +337 -0
  26. package/build/godot-executable.js +156 -0
  27. package/build/godot-process-manager.js +112 -0
  28. package/build/godot-subprocess.js +23 -0
  29. package/build/headless-operation-runner.js +68 -0
  30. package/build/headless-operation-service.js +65 -0
  31. package/build/index.js +478 -0
  32. package/build/interaction-server-installer.js +234 -0
  33. package/build/opencode-setup.js +199 -0
  34. package/build/project-support.js +219 -0
  35. package/build/runtime-protocol.js +202 -0
  36. package/build/scripts/godot_operations.gd +1534 -0
  37. package/build/scripts/mcp_editor_plugin.gd +417 -0
  38. package/build/scripts/mcp_interaction_server.gd +1255 -0
  39. package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
  40. package/build/scripts/mcp_runtime/command_params.gd +339 -0
  41. package/build/scripts/mcp_runtime/core_domain.gd +591 -0
  42. package/build/scripts/mcp_runtime/input_domain.gd +695 -0
  43. package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
  44. package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
  45. package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
  46. package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
  47. package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
  48. package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
  49. package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
  50. package/build/scripts/mcp_runtime/system_domain.gd +277 -0
  51. package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
  52. package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
  53. package/build/scripts/validate_script.gd +28 -0
  54. package/build/server-instructions.js +11 -0
  55. package/build/session-timing.js +20 -0
  56. package/build/tool-argument-validation.js +120 -0
  57. package/build/tool-definitions.js +2727 -0
  58. package/build/tool-handlers/game-tool-handlers.js +1345 -0
  59. package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
  60. package/build/tool-handlers/project-handler-services.js +1458 -0
  61. package/build/tool-handlers/project-tool-handlers.js +1506 -0
  62. package/build/tool-handlers/visual-regression-service.js +139 -0
  63. package/build/tool-manifest.js +1193 -0
  64. package/build/tool-mutation-policy.js +122 -0
  65. package/build/tool-registry.js +34 -0
  66. package/build/tool-surface.js +70 -0
  67. package/build/utils.js +273 -0
  68. package/package.json +110 -0
  69. package/product.json +52 -0
  70. package/scripts/prepare-package.js +42 -0
@@ -0,0 +1,1534 @@
1
+ #!/usr/bin/env -S godot --headless --script
2
+ extends SceneTree
3
+
4
+ # The outcome of one operation. Operations never report errors and never quit:
5
+ # they return this to the entry point, which is the only place that logs
6
+ # failures and chooses the exit code.
7
+ class OperationResult extends RefCounted:
8
+ var ok: bool
9
+ var errors: PackedStringArray
10
+
11
+ func _init(p_ok: bool, p_errors: PackedStringArray) -> void:
12
+ ok = p_ok
13
+ errors = p_errors
14
+
15
+ # A parsed command line: the operation to run and its JSON object parameters,
16
+ # or the reasons the command line could not be used.
17
+ class CliInvocation extends RefCounted:
18
+ var operation: String
19
+ var params: Dictionary
20
+ var errors: PackedStringArray
21
+
22
+ func _init(p_operation: String, p_params: Dictionary, p_errors: PackedStringArray) -> void:
23
+ operation = p_operation
24
+ params = p_params
25
+ errors = p_errors
26
+
27
+ func is_valid() -> bool:
28
+ return errors.is_empty()
29
+
30
+ # Opt-in debug diagnostics. This is constructed only under --debug-godot, so a
31
+ # normal operation never logs diagnostics and never writes a probe file into the
32
+ # user's project. Values that can carry file paths, tokens, or script source are
33
+ # summarized by type and size rather than printed.
34
+ class DebugDiagnostics extends RefCounted:
35
+ const PROBE_PREFIX := "godot_mcp_write_probe_"
36
+
37
+ func log_message(message: String) -> void:
38
+ print("[DEBUG] " + message)
39
+
40
+ # Describe a JSON value without disclosing its contents. JSON values arrive
41
+ # as Variant, so each branch narrows after its own type check.
42
+ func redact(value: Variant) -> String:
43
+ if value is String:
44
+ var text: String = value
45
+ return "<string, " + str(text.length()) + " chars>"
46
+ if value is Dictionary:
47
+ var object: Dictionary = value
48
+ return "<object, " + str(object.size()) + " keys>"
49
+ if value is Array:
50
+ var items: Array = value
51
+ return "<array, " + str(items.size()) + " items>"
52
+ if value == null:
53
+ return "null"
54
+ if value is bool or value is int or value is float:
55
+ return str(value)
56
+ return "<" + type_string(typeof(value)) + ">"
57
+
58
+ func redact_params(params: Dictionary) -> String:
59
+ var parts := PackedStringArray()
60
+ var keys := PackedStringArray(params.keys())
61
+ keys.sort()
62
+ for key in keys:
63
+ @warning_ignore("return_value_discarded")
64
+ parts.append(key + ": " + redact(params[key]))
65
+ return "{" + ", ".join(parts) + "}"
66
+
67
+ func log_project_environment() -> void:
68
+ log_message("Globalized res:// path: " + ProjectSettings.globalize_path("res://"))
69
+ log_message("Globalized user:// path: " + ProjectSettings.globalize_path("user://"))
70
+
71
+ # Only report which variables are set; their values are paths and may
72
+ # carry credentials.
73
+ var set_vars := PackedStringArray()
74
+ for env_var: String in ["PATH", "HOME", "USER", "TEMP", "GODOT_PATH"]:
75
+ if OS.has_environment(env_var):
76
+ @warning_ignore("return_value_discarded")
77
+ set_vars.append(env_var)
78
+ log_message("Environment variables set: " + ", ".join(set_vars))
79
+
80
+ # Check that a directory is writable by creating and deleting one probe file.
81
+ # The probe name is unique per call and the file is removed on every branch,
82
+ # so the directory is left exactly as it was found.
83
+ func probe_write_access(res_dir: String) -> void:
84
+ var probe_path: String = res_dir.path_join(PROBE_PREFIX + str(Time.get_ticks_usec()) + ".tmp")
85
+ if FileAccess.file_exists(probe_path):
86
+ log_message("Skipping write probe, path is already taken: " + probe_path)
87
+ return
88
+
89
+ var probe := FileAccess.open(probe_path, FileAccess.WRITE)
90
+ if probe == null:
91
+ log_message("Write probe failed for " + res_dir + " (open error " + str(FileAccess.get_open_error()) + "); this indicates a permission or path issue")
92
+ return
93
+
94
+ @warning_ignore("return_value_discarded")
95
+ probe.store_string("write probe")
96
+ probe.close()
97
+ log_message("Write probe succeeded for: " + res_dir)
98
+ _remove_probe(probe_path)
99
+
100
+ func _remove_probe(probe_path: String) -> void:
101
+ if not FileAccess.file_exists(probe_path):
102
+ return
103
+ var remove_error: Error = DirAccess.remove_absolute(ProjectSettings.globalize_path(probe_path))
104
+ if remove_error != OK:
105
+ log_message("Failed to remove write probe " + probe_path + " (error " + str(remove_error) + ")")
106
+
107
+ # Debug mode flag
108
+ var debug_mode: bool = false
109
+
110
+ # The diagnostics service, or null when diagnostics are off. Nothing outside
111
+ # log_debug() and the branches guarded by debug_mode may use it.
112
+ var _diagnostics: DebugDiagnostics = null
113
+
114
+ # Operation name -> handler. The single source of truth for which operations
115
+ # exist; the CLI rejects any name that is not registered here.
116
+ var _operations: Dictionary[String, Callable] = {}
117
+ const SERVE_ARGUMENT: String = "--serve-authoring"
118
+ var _capture_output: bool = false
119
+ var _operation_output: PackedStringArray = PackedStringArray()
120
+
121
+ func _init() -> void:
122
+ _register_operations()
123
+
124
+ var args: PackedStringArray = OS.get_cmdline_args()
125
+ debug_mode = "--debug-godot" in args
126
+ if debug_mode:
127
+ _diagnostics = DebugDiagnostics.new()
128
+
129
+ if SERVE_ARGUMENT in args:
130
+ # Autoloads enter the tree after the script main loop is constructed.
131
+ # Keep the deferred startup and its exit decision lexically inside this
132
+ # CLI entry point; helpers report failures but never terminate Godot.
133
+ var start_authoring_session: Callable = func() -> void:
134
+ var startup_errors: PackedStringArray = _start_authoring_session()
135
+ if not startup_errors.is_empty():
136
+ _report_errors(startup_errors)
137
+ quit(1)
138
+ start_authoring_session.call_deferred()
139
+ return
140
+
141
+ var invocation := _parse_cli(args)
142
+ if not invocation.is_valid():
143
+ _report_errors(invocation.errors)
144
+ quit(1)
145
+ return
146
+
147
+ log_info("Executing operation: " + invocation.operation)
148
+
149
+ var handler: Callable = _operations[invocation.operation]
150
+ var result: OperationResult = handler.call(invocation.params)
151
+ if not result.ok:
152
+ _report_errors(result.errors)
153
+ quit(1)
154
+ return
155
+
156
+ quit(0)
157
+
158
+ func _start_authoring_session() -> PackedStringArray:
159
+ var server: Node = get_root().get_node_or_null("McpInteractionServer")
160
+ if server == null:
161
+ return PackedStringArray([
162
+ "Authoring session requires the McpInteractionServer autoload.",
163
+ ])
164
+ if not server.has_method("register_authoring_dispatcher"):
165
+ return PackedStringArray([
166
+ "McpInteractionServer does not support authoring commands.",
167
+ ])
168
+ server.call("register_authoring_dispatcher", execute_operation)
169
+ log_info("Authoring session ready")
170
+ return PackedStringArray()
171
+
172
+ func execute_operation(operation: String, params: Dictionary) -> Dictionary:
173
+ if not _operations.has(operation):
174
+ return {
175
+ "error": "Unknown authoring operation: " + operation,
176
+ "error_data": {
177
+ "reason": "unknown_authoring_operation",
178
+ "operation": operation,
179
+ },
180
+ }
181
+ _operation_output.clear()
182
+ _capture_output = true
183
+ log_info("Executing operation: " + operation)
184
+ var handler: Callable = _operations[operation]
185
+ var result: OperationResult = handler.call(params)
186
+ _capture_output = false
187
+ if not result.ok:
188
+ return {
189
+ "error": "\n".join(result.errors),
190
+ "error_data": {
191
+ "reason": "authoring_operation_failed",
192
+ "operation": operation,
193
+ "errors": Array(result.errors),
194
+ },
195
+ }
196
+ return {
197
+ "success": true,
198
+ "operation": operation,
199
+ "stdout": "\n".join(_operation_output),
200
+ }
201
+
202
+ func _register_operations() -> void:
203
+ _operations = {
204
+ "create_scene": create_scene,
205
+ "add_node": add_node,
206
+ "load_sprite": load_sprite,
207
+ "export_mesh_library": export_mesh_library,
208
+ "save_scene": save_scene,
209
+ "get_uid": get_uid,
210
+ "resave_resources": resave_resources,
211
+ "read_scene": read_scene,
212
+ "modify_node": modify_node,
213
+ "remove_node": remove_node,
214
+ "attach_script": attach_script,
215
+ "create_resource": create_resource,
216
+ "manage_resource": manage_resource,
217
+ "manage_scene_signals": manage_scene_signals,
218
+ "manage_theme_resource": manage_theme_resource,
219
+ "manage_scene_structure": manage_scene_structure,
220
+ }
221
+
222
+ # Parse the Godot command line into an operation and its parameters. The
223
+ # operation name and the JSON object are both validated before any dispatch.
224
+ func _parse_cli(args: PackedStringArray) -> CliInvocation:
225
+ var script_index: int = args.find("--script")
226
+ if script_index == -1:
227
+ return _invalid_cli("Could not find --script argument")
228
+
229
+ # The operation follows the script path, and the params follow the operation.
230
+ var operation_index: int = script_index + 2
231
+ var params_index: int = script_index + 3
232
+
233
+ if args.size() <= params_index:
234
+ return _invalid_cli(
235
+ "Usage: godot --headless --script godot_operations.gd <operation> <json_params>",
236
+ PackedStringArray(["Not enough command-line arguments provided."])
237
+ )
238
+
239
+ # The arguments carry the params JSON, which can hold paths or script
240
+ # source, so only their positions are logged.
241
+ log_debug("Argument count: " + str(args.size()))
242
+ log_debug("Script index: " + str(script_index))
243
+ log_debug("Operation index: " + str(operation_index))
244
+ log_debug("Params index: " + str(params_index))
245
+
246
+ var operation: String = args[operation_index]
247
+ var params_json: String = args[params_index]
248
+
249
+ log_info("Operation: " + operation)
250
+ log_debug("Params JSON: " + str(params_json.length()) + " chars")
251
+
252
+ if not _operations.has(operation):
253
+ return _invalid_cli(
254
+ "Unknown operation: " + operation,
255
+ PackedStringArray(["Known operations: " + ", ".join(_operation_names())])
256
+ )
257
+
258
+ var json := JSON.new()
259
+ var parse_error: Error = json.parse(params_json)
260
+ if parse_error != OK:
261
+ return _invalid_cli(
262
+ "Failed to parse JSON parameters: " + params_json,
263
+ PackedStringArray(["JSON Error: " + json.get_error_message() + " at line " + str(json.get_error_line())])
264
+ )
265
+
266
+ var data: Variant = json.get_data()
267
+ if not (data is Dictionary):
268
+ return _invalid_cli("Parameters must be a JSON object: " + params_json)
269
+
270
+ var operation_params: Dictionary = data
271
+ log_debug("Params: " + _redact_params(operation_params))
272
+ return CliInvocation.new(operation, operation_params, PackedStringArray())
273
+
274
+ func _operation_names() -> PackedStringArray:
275
+ var names := PackedStringArray(_operations.keys())
276
+ names.sort()
277
+ return names
278
+
279
+ func _invalid_cli(message: String, details: PackedStringArray = PackedStringArray()) -> CliInvocation:
280
+ return CliInvocation.new("", {}, _error_lines(message, details))
281
+
282
+ # Result constructors used by every operation instead of printerr()/quit(1).
283
+ func _ok() -> OperationResult:
284
+ return OperationResult.new(true, PackedStringArray())
285
+
286
+ func _fail(message: String, details: PackedStringArray = PackedStringArray()) -> OperationResult:
287
+ return OperationResult.new(false, _error_lines(message, details))
288
+
289
+ func _failed(errors: PackedStringArray) -> OperationResult:
290
+ return OperationResult.new(false, errors)
291
+
292
+ func _error_lines(message: String, details: PackedStringArray) -> PackedStringArray:
293
+ var lines := PackedStringArray([message])
294
+ lines.append_array(details)
295
+ return lines
296
+
297
+ func _report_errors(errors: PackedStringArray) -> void:
298
+ for line in errors:
299
+ log_error(line)
300
+
301
+ # Logging functions
302
+ func log_debug(message: String) -> void:
303
+ if _diagnostics != null:
304
+ _diagnostics.log_message(message)
305
+
306
+ # Summaries used when a debug line would otherwise print a value that can carry
307
+ # a file path, a token, or script source.
308
+ func _redact(value: Variant) -> String:
309
+ if _diagnostics == null:
310
+ return ""
311
+ return _diagnostics.redact(value)
312
+
313
+ func _redact_params(params: Dictionary) -> String:
314
+ if _diagnostics == null:
315
+ return ""
316
+ return _diagnostics.redact_params(params)
317
+
318
+ func log_info(message: String) -> void:
319
+ _emit_output("[INFO] " + message)
320
+
321
+ func _emit_output(message: String) -> void:
322
+ print(message)
323
+ if _capture_output:
324
+ @warning_ignore("return_value_discarded")
325
+ _operation_output.append(message)
326
+
327
+ func log_error(message: String) -> void:
328
+ printerr("[ERROR] " + message)
329
+
330
+ # Parameter readers: JSON values arrive as Variant, so every read narrows to the
331
+ # type the operation works with instead of relying on dynamic access.
332
+ func _param_string(params: Dictionary, key: String, default_value: String = "") -> String:
333
+ if not params.has(key):
334
+ return default_value
335
+ var value: Variant = params[key]
336
+ if value is String:
337
+ return value
338
+ return str(value)
339
+
340
+ func _param_dictionary(params: Dictionary, key: String) -> Dictionary:
341
+ var value: Variant = params.get(key, {})
342
+ if value is Dictionary:
343
+ return value
344
+ return {}
345
+
346
+ func _param_array(params: Dictionary, key: String) -> Array:
347
+ var value: Variant = params.get(key, [])
348
+ if value is Array:
349
+ return value
350
+ return []
351
+
352
+ # --- Project paths ---------------------------------------------------------
353
+ #
354
+ # Tool callers may pass either a project-relative path or a full res:// path,
355
+ # so every operation normalizes through _res_path() instead of open-coding the
356
+ # prefix check.
357
+
358
+ const RES_PREFIX: String = "res://"
359
+
360
+ func _res_path(path: String) -> String:
361
+ if path.begins_with(RES_PREFIX):
362
+ return path
363
+ return RES_PREFIX + path
364
+
365
+ # The path relative to the project root, which is what DirAccess calls rooted
366
+ # at res:// expect.
367
+ func _res_relative(res_path: String) -> String:
368
+ return res_path.substr(RES_PREFIX.length())
369
+
370
+ # --- Directories -----------------------------------------------------------
371
+
372
+ # Create a res:// directory and its parents if they are missing.
373
+ func _ensure_directory(res_dir: String) -> OperationResult:
374
+ var relative: String = _res_relative(res_dir)
375
+ if relative.is_empty():
376
+ return _ok()
377
+
378
+ var absolute: String = ProjectSettings.globalize_path(res_dir)
379
+ if DirAccess.dir_exists_absolute(absolute):
380
+ log_debug("Directory already exists: " + res_dir)
381
+ return _ok()
382
+
383
+ var dir := DirAccess.open(RES_PREFIX)
384
+ if dir == null:
385
+ # res:// itself could not be opened, so fall back to the absolute path.
386
+ var open_error: Error = DirAccess.get_open_error()
387
+ log_debug("Failed to open res:// (error " + str(open_error) + "), creating by absolute path")
388
+ var absolute_error: Error = DirAccess.make_dir_recursive_absolute(absolute)
389
+ if absolute_error != OK:
390
+ return _fail("Failed to create directory: " + res_dir, PackedStringArray([
391
+ "DirAccess error: " + str(open_error),
392
+ "Error code: " + str(absolute_error),
393
+ ]))
394
+ else:
395
+ var make_error: Error = dir.make_dir_recursive(relative)
396
+ if make_error != OK:
397
+ return _fail("Failed to create directory: " + res_dir, PackedStringArray([
398
+ "Error code: " + str(make_error),
399
+ ]))
400
+
401
+ if not DirAccess.dir_exists_absolute(absolute):
402
+ return _fail("Directory reported as created but does not exist: " + absolute, PackedStringArray([
403
+ "This may indicate a problem with path resolution or permissions",
404
+ ]))
405
+
406
+ log_debug("Created directory: " + res_dir)
407
+ return _ok()
408
+
409
+ # --- Scene loading, packing and saving -------------------------------------
410
+
411
+ # A scene file opened for editing: its instantiated root, or the reasons it
412
+ # could not be opened. The instantiated tree is never added to the running
413
+ # SceneTree and queue_free() would never be processed before quit(), so the
414
+ # guard frees the root when the OpenScene itself is released; without it every
415
+ # early return leaks the whole tree as ObjectDB instances at exit.
416
+ class OpenScene extends RefCounted:
417
+ var root: Node
418
+ var errors: PackedStringArray
419
+
420
+ func _init(p_root: Node, p_errors: PackedStringArray) -> void:
421
+ root = p_root
422
+ errors = p_errors
423
+
424
+ func is_valid() -> bool:
425
+ return errors.is_empty()
426
+
427
+ func _notification(what: int) -> void:
428
+ if what == NOTIFICATION_PREDELETE and root != null and is_instance_valid(root):
429
+ root.free()
430
+
431
+ # Load a scene file and instantiate it so an operation can edit the tree.
432
+ func _open_scene(res_scene_path: String) -> OpenScene:
433
+ if not FileAccess.file_exists(res_scene_path):
434
+ return _scene_not_opened("Scene file does not exist at: " + res_scene_path, PackedStringArray([
435
+ "Absolute file path that doesn't exist: " + ProjectSettings.globalize_path(res_scene_path),
436
+ ]))
437
+
438
+ var scene := load(res_scene_path) as PackedScene
439
+ if scene == null:
440
+ return _scene_not_opened("Failed to load scene: " + res_scene_path)
441
+
442
+ var scene_root := scene.instantiate()
443
+ if scene_root == null:
444
+ return _scene_not_opened("Failed to instantiate scene: " + res_scene_path)
445
+
446
+ log_debug("Scene instantiated: " + res_scene_path)
447
+ return OpenScene.new(scene_root, PackedStringArray())
448
+
449
+ func _scene_not_opened(message: String, details: PackedStringArray = PackedStringArray()) -> OpenScene:
450
+ return OpenScene.new(null, _error_lines(message, details))
451
+
452
+ # Resolve a tool-supplied node path against a scene root. Callers address the
453
+ # root as "", "root" or ".", and its descendants with or without a "root/"
454
+ # prefix.
455
+ func _resolve_scene_node(scene_root: Node, tool_path: String) -> Node:
456
+ if tool_path == "" or tool_path == "root" or tool_path == ".":
457
+ return scene_root
458
+ var p: String = tool_path
459
+ if p.begins_with("root/"):
460
+ p = p.substr(5)
461
+ return scene_root.get_node_or_null(p)
462
+
463
+ # Pack an edited scene root and write it back to a res:// path.
464
+ func _save_scene_root(scene_root: Node, res_scene_path: String) -> OperationResult:
465
+ var packed := PackedScene.new()
466
+ var pack_error: Error = packed.pack(scene_root)
467
+ if pack_error != OK:
468
+ return _fail("Failed to pack scene: " + res_scene_path, PackedStringArray([
469
+ "Error code: " + str(pack_error),
470
+ ]))
471
+
472
+ return _save_resource(packed, res_scene_path, "scene")
473
+
474
+ # Save a resource to a res:// path, creating its directory first and verifying
475
+ # the file exists afterwards.
476
+ func _save_resource(resource: Resource, res_path: String, what: String) -> OperationResult:
477
+ var dir_result := _ensure_directory(res_path.get_base_dir())
478
+ if not dir_result.ok:
479
+ return dir_result
480
+
481
+ var save_error: Error = ResourceSaver.save(resource, res_path)
482
+ if save_error != OK:
483
+ return _fail("Failed to save " + what + ": " + str(save_error), PackedStringArray([
484
+ "Path: " + res_path + _save_error_hint(save_error),
485
+ ]))
486
+
487
+ if not FileAccess.file_exists(res_path):
488
+ return _fail("File reported as saved but does not exist at: " + res_path)
489
+
490
+ log_debug("Saved " + what + " to: " + res_path)
491
+ return _ok()
492
+
493
+ func _save_error_hint(save_error: Error) -> String:
494
+ match save_error:
495
+ ERR_CANT_CREATE:
496
+ return " (ERR_CANT_CREATE - Cannot create the file)"
497
+ ERR_CANT_OPEN:
498
+ return " (ERR_CANT_OPEN - Cannot open the file for writing)"
499
+ ERR_FILE_CANT_WRITE:
500
+ return " (ERR_FILE_CANT_WRITE - Cannot write to the file)"
501
+ ERR_FILE_NO_PERMISSION:
502
+ return " (ERR_FILE_NO_PERMISSION - No permission to write the file)"
503
+ return ""
504
+
505
+ # --- Resource loading ------------------------------------------------------
506
+
507
+ # An existing resource opened for reading or modification.
508
+ class OpenResource extends RefCounted:
509
+ var resource: Resource
510
+ var errors: PackedStringArray
511
+
512
+ func _init(p_resource: Resource, p_errors: PackedStringArray) -> void:
513
+ resource = p_resource
514
+ errors = p_errors
515
+
516
+ func is_valid() -> bool:
517
+ return errors.is_empty()
518
+
519
+ # `what` names the resource in failures ("Resource", "Theme"), so callers keep
520
+ # their domain wording without repeating the exists/load dance.
521
+ func _open_resource(res_path: String, what: String) -> OpenResource:
522
+ if not ResourceLoader.exists(res_path):
523
+ return OpenResource.new(null, PackedStringArray([what + " not found: " + res_path]))
524
+
525
+ var resource := ResourceLoader.load(res_path)
526
+ if resource == null:
527
+ return OpenResource.new(null, PackedStringArray(["Failed to load " + what.to_lower() + ": " + res_path]))
528
+
529
+ return OpenResource.new(resource, PackedStringArray())
530
+
531
+ # Apply JSON properties to a node or resource, converting each value to the
532
+ # type the target property declares.
533
+ func _apply_properties(target: Object, properties: Dictionary) -> void:
534
+ for prop_name: String in properties:
535
+ var raw_value: Variant = properties[prop_name]
536
+ var converted_value: Variant = _convert_property_value(target, prop_name, raw_value)
537
+ log_debug("Setting " + prop_name + " = " + _redact(converted_value) + " (from " + _redact(raw_value) + ")")
538
+ target.set(prop_name, converted_value)
539
+
540
+ # --- Class and script resolution -------------------------------------------
541
+
542
+ # Get a script by name or path
543
+ func get_script_by_name(name_of_class: String) -> Script:
544
+ log_debug("Attempting to get script for class: " + name_of_class)
545
+
546
+ # Try to load it directly if it's a resource path
547
+ if ResourceLoader.exists(name_of_class, "Script"):
548
+ log_debug("Resource exists, loading directly: " + name_of_class)
549
+ var script := load(name_of_class) as Script
550
+ if script:
551
+ log_debug("Successfully loaded script from path")
552
+ return script
553
+ else:
554
+ printerr("Failed to load script from path: " + name_of_class)
555
+ else:
556
+ log_debug("Resource not found, checking global class registry")
557
+
558
+ # Search for it in the global class registry if it's a class name
559
+ var global_classes: Array[Dictionary] = ProjectSettings.get_global_class_list()
560
+ log_debug("Searching through " + str(global_classes.size()) + " global classes")
561
+
562
+ for global_class in global_classes:
563
+ var found_name_of_class: String = global_class["class"]
564
+ var found_path: String = global_class["path"]
565
+
566
+ if found_name_of_class == name_of_class:
567
+ log_debug("Found matching class in registry: " + found_name_of_class + " at path: " + found_path)
568
+ var script := load(found_path) as Script
569
+ if script:
570
+ log_debug("Successfully loaded script from registry")
571
+ return script
572
+ else:
573
+ printerr("Failed to load script from registry path: " + found_path)
574
+ break
575
+
576
+ printerr("Could not find script for class: " + name_of_class)
577
+ return null
578
+
579
+ # Instantiate a class by name
580
+ func instantiate_class(name_of_class: String) -> Object:
581
+ if name_of_class.is_empty():
582
+ printerr("Cannot instantiate class: name is empty")
583
+ return null
584
+
585
+ var result: Object = null
586
+ log_debug("Attempting to instantiate class: " + name_of_class)
587
+
588
+ # Check if it's a built-in class
589
+ if ClassDB.class_exists(name_of_class):
590
+ log_debug("Class exists in ClassDB, using ClassDB.instantiate()")
591
+ if ClassDB.can_instantiate(name_of_class):
592
+ result = ClassDB.instantiate(name_of_class)
593
+ if result == null:
594
+ printerr("ClassDB.instantiate() returned null for class: " + name_of_class)
595
+ else:
596
+ printerr("Class exists but cannot be instantiated: " + name_of_class)
597
+ printerr("This may be an abstract class or interface that cannot be directly instantiated")
598
+ else:
599
+ # Try to get the script
600
+ log_debug("Class not found in ClassDB, trying to get script")
601
+ var script := get_script_by_name(name_of_class)
602
+ if script is GDScript:
603
+ log_debug("Found GDScript, creating instance")
604
+ result = (script as GDScript).new()
605
+ else:
606
+ printerr("Failed to get script for class: " + name_of_class)
607
+ return null
608
+
609
+ if result == null:
610
+ printerr("Failed to instantiate class: " + name_of_class)
611
+ else:
612
+ log_debug("Successfully instantiated class: " + name_of_class + " of type: " + result.get_class())
613
+
614
+ return result
615
+
616
+ # Instantiate a class that must be a Node (scene operations)
617
+ func instantiate_node(name_of_class: String) -> Node:
618
+ return instantiate_class(name_of_class) as Node
619
+
620
+ # Create a new scene with a specified root node type
621
+ func create_scene(params: Dictionary) -> OperationResult:
622
+ var scene_path: String = _param_string(params, "scene_path")
623
+ _emit_output("Creating scene: " + scene_path)
624
+
625
+ if _diagnostics != null:
626
+ _diagnostics.log_project_environment()
627
+ _diagnostics.probe_write_access(RES_PREFIX)
628
+
629
+ var full_scene_path: String = _res_path(scene_path)
630
+ var root_node_type: String = _param_string(params, "root_node_type", "Node2D")
631
+ log_debug("Scene path: " + full_scene_path + ", root node type: " + root_node_type)
632
+
633
+ var scene_root := instantiate_node(root_node_type)
634
+ if scene_root == null:
635
+ return _fail("Failed to instantiate node of type: " + root_node_type, PackedStringArray([
636
+ "Make sure the class exists and can be instantiated",
637
+ "Check if the class is registered in ClassDB or available as a script",
638
+ ]))
639
+
640
+ # PackedScene.pack() stores the root plus the nodes it owns. The root itself
641
+ # needs no owner: Node.set_owner() rejects a node owning itself, so assigning
642
+ # one only printed an engine error.
643
+ scene_root.name = "root"
644
+
645
+ var save_result := _save_scene_root(scene_root, full_scene_path)
646
+ # The root never joins the SceneTree, so it must be freed explicitly or it
647
+ # leaks an ObjectDB instance at exit.
648
+ scene_root.free()
649
+ if not save_result.ok:
650
+ if _diagnostics != null:
651
+ _diagnostics.probe_write_access(full_scene_path.get_base_dir())
652
+ return save_result
653
+
654
+ _emit_output("Scene created successfully at: " + scene_path)
655
+ return _ok()
656
+
657
+ # Add a node to an existing scene
658
+ func add_node(params: Dictionary) -> OperationResult:
659
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
660
+ _emit_output("Adding node to scene: " + full_scene_path)
661
+
662
+ var opened := _open_scene(full_scene_path)
663
+ if not opened.is_valid():
664
+ return _failed(opened.errors)
665
+ var scene_root: Node = opened.root
666
+
667
+ var parent_path: String = _param_string(params, "parent_node_path", "root")
668
+ var parent := _resolve_scene_node(scene_root, parent_path)
669
+ if parent == null:
670
+ return _fail("Parent node not found: " + parent_path)
671
+
672
+ var node_type: String = _param_string(params, "node_type")
673
+ var new_node := instantiate_node(node_type)
674
+ if new_node == null:
675
+ return _fail("Failed to instantiate node of type: " + node_type, PackedStringArray([
676
+ "Make sure the class exists and can be instantiated",
677
+ "Check if the class is registered in ClassDB or available as a script",
678
+ ]))
679
+
680
+ var node_name: String = _param_string(params, "node_name")
681
+ new_node.name = node_name
682
+ _apply_properties(new_node, _param_dictionary(params, "properties"))
683
+
684
+ # force_readable_name matches editor semantics: a duplicate name becomes
685
+ # Twin2 rather than the transient @Twin@N, which PackedScene will not
686
+ # persist — without this the second node silently vanished from the file.
687
+ parent.add_child(new_node, true)
688
+ new_node.owner = scene_root
689
+
690
+ var save_result := _save_scene_root(scene_root, full_scene_path)
691
+ if not save_result.ok:
692
+ return save_result
693
+
694
+ _emit_output("Node '" + node_name + "' of type '" + node_type + "' added successfully")
695
+ return _ok()
696
+
697
+ # Load a sprite into a Sprite2D node
698
+ func load_sprite(params: Dictionary) -> OperationResult:
699
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
700
+ var full_texture_path: String = _res_path(_param_string(params, "texture_path"))
701
+ _emit_output("Loading sprite into scene: " + full_scene_path)
702
+
703
+ var opened := _open_scene(full_scene_path)
704
+ if not opened.is_valid():
705
+ return _failed(opened.errors)
706
+ var scene_root: Node = opened.root
707
+
708
+ # An empty node path means the root node is the sprite.
709
+ var node_path: String = _param_string(params, "node_path")
710
+ var sprite_node := _resolve_scene_node(scene_root, node_path)
711
+ if sprite_node == null:
712
+ return _fail("Node not found: " + node_path)
713
+
714
+ if not (sprite_node is Sprite2D or sprite_node is Sprite3D or sprite_node is TextureRect):
715
+ return _fail("Node is not a sprite-compatible type: " + sprite_node.get_class())
716
+
717
+ var texture := load(full_texture_path) as Texture2D
718
+ if texture == null:
719
+ return _fail("Failed to load texture: " + full_texture_path)
720
+
721
+ if sprite_node is Sprite2D:
722
+ (sprite_node as Sprite2D).texture = texture
723
+ elif sprite_node is Sprite3D:
724
+ (sprite_node as Sprite3D).texture = texture
725
+ else:
726
+ (sprite_node as TextureRect).texture = texture
727
+
728
+ var save_result := _save_scene_root(scene_root, full_scene_path)
729
+ if not save_result.ok:
730
+ return save_result
731
+
732
+ _emit_output("Sprite loaded successfully with texture: " + full_texture_path)
733
+ return _ok()
734
+
735
+ # Export a scene as a MeshLibrary resource
736
+ func export_mesh_library(params: Dictionary) -> OperationResult:
737
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
738
+ var full_output_path: String = _res_path(_param_string(params, "output_path"))
739
+ _emit_output("Exporting MeshLibrary from scene: " + full_scene_path)
740
+
741
+ var opened := _open_scene(full_scene_path)
742
+ if not opened.is_valid():
743
+ return _failed(opened.errors)
744
+ var scene_root: Node = opened.root
745
+
746
+ # An empty list means every mesh in the scene.
747
+ var mesh_item_names: Array = _param_array(params, "mesh_item_names")
748
+ var use_specific_items: bool = mesh_item_names.size() > 0
749
+
750
+ var mesh_library := MeshLibrary.new()
751
+ var item_id: int = 0
752
+
753
+ for child in scene_root.get_children():
754
+ var child_name: String = String(child.name)
755
+ if use_specific_items and not (child_name in mesh_item_names):
756
+ log_debug("Skipping node " + child_name + " (not in specified items list)")
757
+ continue
758
+
759
+ var mesh_instance := _find_mesh_instance(child)
760
+ if mesh_instance == null or mesh_instance.mesh == null:
761
+ log_debug("Node " + child_name + " has no valid mesh")
762
+ continue
763
+
764
+ mesh_library.create_item(item_id)
765
+ mesh_library.set_item_name(item_id, child_name)
766
+ mesh_library.set_item_mesh(item_id, mesh_instance.mesh)
767
+
768
+ var shape := _find_collision_shape(child)
769
+ if shape != null:
770
+ mesh_library.set_item_shapes(item_id, [shape])
771
+
772
+ log_debug("Added mesh '" + child_name + "' to library with ID: " + str(item_id))
773
+ item_id += 1
774
+
775
+ if item_id == 0:
776
+ return _fail("No valid meshes found in the scene")
777
+
778
+ var save_result := _save_resource(mesh_library, full_output_path, "MeshLibrary")
779
+ if not save_result.ok:
780
+ return save_result
781
+
782
+ _emit_output("MeshLibrary exported successfully with " + str(item_id) + " items to: " + full_output_path)
783
+ return _ok()
784
+
785
+ # A library item's mesh is either the node itself or one of its direct children.
786
+ func _find_mesh_instance(node: Node) -> MeshInstance3D:
787
+ if node is MeshInstance3D:
788
+ return node
789
+ for child in node.get_children():
790
+ if child is MeshInstance3D:
791
+ return child
792
+ return null
793
+
794
+ func _find_collision_shape(node: Node) -> Shape3D:
795
+ for child in node.get_children():
796
+ if child is CollisionShape3D and (child as CollisionShape3D).shape != null:
797
+ return (child as CollisionShape3D).shape
798
+ return null
799
+
800
+ # Find files with a specific extension recursively
801
+ func find_files(path: String, extension: String) -> PackedStringArray:
802
+ var files := PackedStringArray()
803
+ var dir := DirAccess.open(path)
804
+
805
+ if dir:
806
+ @warning_ignore("return_value_discarded")
807
+ dir.list_dir_begin()
808
+ var file_name: String = dir.get_next()
809
+
810
+ while file_name != "":
811
+ if dir.current_is_dir() and not file_name.begins_with("."):
812
+ files.append_array(find_files(path + file_name + "/", extension))
813
+ elif file_name.ends_with(extension):
814
+ @warning_ignore("return_value_discarded")
815
+ files.append(path + file_name)
816
+
817
+ file_name = dir.get_next()
818
+
819
+ return files
820
+
821
+ # Get UID for a specific file
822
+ func get_uid(params: Dictionary) -> OperationResult:
823
+ if not params.has("file_path"):
824
+ return _fail("File path is required")
825
+
826
+ var file_path: String = _res_path(_param_string(params, "file_path"))
827
+ var absolute_path: String = ProjectSettings.globalize_path(file_path)
828
+ _emit_output("Getting UID for file: " + file_path)
829
+
830
+ if not FileAccess.file_exists(file_path):
831
+ return _fail("File does not exist at: " + file_path, PackedStringArray([
832
+ "Absolute file path that doesn't exist: " + absolute_path,
833
+ ]))
834
+
835
+ var result: Dictionary = {
836
+ "file": file_path,
837
+ "absolutePath": absolute_path,
838
+ }
839
+
840
+ var uid_file := FileAccess.open(file_path + ".uid", FileAccess.READ)
841
+ if uid_file != null:
842
+ var uid_content: String = uid_file.get_as_text()
843
+ uid_file.close()
844
+ result["uid"] = uid_content.strip_edges()
845
+ result["exists"] = true
846
+ else:
847
+ log_debug("UID file does not exist or could not be opened for: " + file_path)
848
+ result["exists"] = false
849
+ result["message"] = "UID file does not exist for this file. Use resave_resources to generate UIDs."
850
+
851
+ _emit_output(JSON.stringify(result))
852
+ return _ok()
853
+
854
+ # Resave all resources to update UID references
855
+ func resave_resources(params: Dictionary) -> OperationResult:
856
+ _emit_output("Resaving all resources to update UID references...")
857
+
858
+ var project_path: String = RES_PREFIX
859
+ if params.has("project_path"):
860
+ project_path = _res_path(_param_string(params, "project_path"))
861
+ if not project_path.ends_with("/"):
862
+ project_path += "/"
863
+ log_debug("Using project path: " + project_path)
864
+
865
+ # Per-file problems accumulate here instead of being printed and quit on,
866
+ # so the entry point reports them together.
867
+ var errors: Array[String] = []
868
+
869
+ var scenes := find_files(project_path, ".tscn")
870
+ var saved_scenes: int = 0
871
+ for scene_path in scenes:
872
+ var scene := load(scene_path) as Resource
873
+ if scene == null:
874
+ errors.append("Failed to load: " + scene_path)
875
+ continue
876
+
877
+ var save_result := _save_resource(scene, scene_path, "scene")
878
+ if save_result.ok:
879
+ saved_scenes += 1
880
+ else:
881
+ errors.append_array(save_result.errors)
882
+
883
+ # A missing .uid sidecar is regenerated by resaving the script or shader.
884
+ var scripts := find_files(project_path, ".gd") + find_files(project_path, ".shader") + find_files(project_path, ".gdshader")
885
+ var missing_uids: int = 0
886
+ var generated_uids: int = 0
887
+
888
+ for script_path in scripts:
889
+ var uid_path: String = script_path + ".uid"
890
+ if FileAccess.file_exists(uid_path):
891
+ continue
892
+
893
+ missing_uids += 1
894
+ # ResourceSaver does not write .uid sidecars for scripts outside the
895
+ # editor's import pipeline (verified on 4.7: resaving a .gd leaves no
896
+ # sidecar), so create and persist a fresh UID explicitly, matching the
897
+ # sidecar format the importer writes.
898
+ var new_id: int = ResourceUID.create_id()
899
+ var uid_file := FileAccess.open(uid_path, FileAccess.WRITE)
900
+ if uid_file == null:
901
+ errors.append("Failed to write UID file: " + uid_path)
902
+ continue
903
+ @warning_ignore("return_value_discarded")
904
+ uid_file.store_line(ResourceUID.id_to_text(new_id))
905
+ uid_file.close()
906
+ if not ResourceUID.has_id(new_id):
907
+ ResourceUID.add_id(new_id, script_path)
908
+ generated_uids += 1
909
+
910
+ log_debug("Summary:")
911
+ log_debug("- Scenes processed: " + str(scenes.size()))
912
+ log_debug("- Scenes successfully saved: " + str(saved_scenes))
913
+ log_debug("- Scripts/shaders missing UIDs: " + str(missing_uids))
914
+ log_debug("- UIDs successfully generated: " + str(generated_uids))
915
+ log_debug("- Errors: " + str(errors.size()))
916
+ _emit_output("Resave operation complete")
917
+
918
+ if not errors.is_empty():
919
+ return _failed(PackedStringArray(errors))
920
+ return _ok()
921
+
922
+ # Save changes to a scene file
923
+ func save_scene(params: Dictionary) -> OperationResult:
924
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
925
+ _emit_output("Saving scene: " + full_scene_path)
926
+
927
+ var opened := _open_scene(full_scene_path)
928
+ if not opened.is_valid():
929
+ return _failed(opened.errors)
930
+
931
+ # A new path saves a copy; otherwise the scene is rewritten in place.
932
+ var save_path: String = full_scene_path
933
+ if params.has("new_path"):
934
+ save_path = _res_path(_param_string(params, "new_path"))
935
+
936
+ var save_result := _save_scene_root(opened.root, save_path)
937
+ if not save_result.ok:
938
+ return save_result
939
+
940
+ _emit_output("Scene saved successfully to: " + save_path)
941
+ return _ok()
942
+
943
+ # JSON decodes into Variant, so the conversions below are the one place where
944
+ # untyped values are narrowed. Every suppression in this file lives here.
945
+
946
+ # Helper: Narrow a JSON scalar to float
947
+ func _variant_to_float(value: Variant, default_value: float = 0.0) -> float:
948
+ if value is float or value is int or value is bool:
949
+ @warning_ignore("unsafe_call_argument")
950
+ return float(value)
951
+ if value is String:
952
+ var text: String = value
953
+ return text.to_float()
954
+ return default_value
955
+
956
+ # Helper: Narrow a JSON scalar to int (JSON numbers decode as float)
957
+ func _variant_to_int(value: Variant, default_value: int = 0) -> int:
958
+ if value is float or value is int or value is bool:
959
+ @warning_ignore("unsafe_call_argument", "narrowing_conversion")
960
+ return int(value)
961
+ if value is String:
962
+ var text: String = value
963
+ return text.to_int()
964
+ return default_value
965
+
966
+ # Helper: Narrow a JSON scalar to bool
967
+ func _variant_to_bool(value: Variant) -> bool:
968
+ if value is bool:
969
+ return value
970
+ if value is float or value is int:
971
+ return not is_zero_approx(_variant_to_float(value))
972
+ if value is String:
973
+ var text: String = value
974
+ return text.to_lower() == "true"
975
+ return false
976
+
977
+ # Helper: Read a float member out of a JSON object, tolerating ints
978
+ func _json_float(source: Dictionary, key: String, default_value: float = 0.0) -> float:
979
+ return _variant_to_float(source.get(key, default_value), default_value)
980
+
981
+ # Helper: Read an int member out of a JSON object
982
+ func _json_int(source: Dictionary, key: String, default_value: int = 0) -> int:
983
+ return _variant_to_int(source.get(key, default_value), default_value)
984
+
985
+ # Helper: Read a nested JSON object member
986
+ func _json_object(source: Dictionary, key: String) -> Dictionary:
987
+ var value: Variant = source.get(key, {})
988
+ if value is Dictionary:
989
+ return value
990
+ return {}
991
+
992
+ # Helper: Convert a JSON value to the correct Godot type based on a node's property type
993
+ func _convert_property_value(node: Object, prop_name: String, value: Variant) -> Variant:
994
+ for prop in node.get_property_list():
995
+ if prop["name"] == prop_name:
996
+ var type_id: int = _variant_to_int(prop.get("type", 0))
997
+ return _convert_to_property_type(type_id, value)
998
+ return value
999
+
1000
+ # Helper: Convert a JSON value to a specific Variant type, or return it unchanged
1001
+ # when the value does not carry the shape that type needs
1002
+ func _convert_to_property_type(type_id: int, value: Variant) -> Variant:
1003
+ var obj: Dictionary = value if value is Dictionary else {}
1004
+ var text: String = value if value is String else ""
1005
+
1006
+ match type_id:
1007
+ TYPE_VECTOR2:
1008
+ if obj.has("x") and obj.has("y"):
1009
+ return Vector2(_json_float(obj, "x"), _json_float(obj, "y"))
1010
+ TYPE_VECTOR2I:
1011
+ if obj.has("x") and obj.has("y"):
1012
+ return Vector2i(_json_int(obj, "x"), _json_int(obj, "y"))
1013
+ TYPE_VECTOR3:
1014
+ if obj.has("x") and obj.has("y"):
1015
+ return Vector3(_json_float(obj, "x"), _json_float(obj, "y"), _json_float(obj, "z"))
1016
+ TYPE_VECTOR3I:
1017
+ if obj.has("x") and obj.has("y"):
1018
+ return Vector3i(_json_int(obj, "x"), _json_int(obj, "y"), _json_int(obj, "z"))
1019
+ TYPE_COLOR:
1020
+ if obj.has("r") and obj.has("g") and obj.has("b"):
1021
+ return Color(_json_float(obj, "r"), _json_float(obj, "g"), _json_float(obj, "b"), _json_float(obj, "a", 1.0))
1022
+ if text.begins_with("#"):
1023
+ return Color.html(text)
1024
+ TYPE_QUATERNION:
1025
+ if value is Dictionary:
1026
+ return Quaternion(_json_float(obj, "x"), _json_float(obj, "y"), _json_float(obj, "z"), _json_float(obj, "w", 1.0))
1027
+ TYPE_RECT2:
1028
+ if obj.has("position") and obj.has("size"):
1029
+ var pos: Dictionary = _json_object(obj, "position")
1030
+ var sz: Dictionary = _json_object(obj, "size")
1031
+ return Rect2(_json_float(pos, "x"), _json_float(pos, "y"), _json_float(sz, "x"), _json_float(sz, "y"))
1032
+ TYPE_AABB:
1033
+ if obj.has("position") and obj.has("size"):
1034
+ var pos: Dictionary = _json_object(obj, "position")
1035
+ var sz: Dictionary = _json_object(obj, "size")
1036
+ return AABB(
1037
+ Vector3(_json_float(pos, "x"), _json_float(pos, "y"), _json_float(pos, "z")),
1038
+ Vector3(_json_float(sz, "x"), _json_float(sz, "y"), _json_float(sz, "z"))
1039
+ )
1040
+ TYPE_BASIS:
1041
+ if obj.has("x") and obj.has("y") and obj.has("z"):
1042
+ return _json_basis(obj)
1043
+ TYPE_TRANSFORM3D:
1044
+ if obj.has("basis") and obj.has("origin"):
1045
+ var basis_d: Dictionary = _json_object(obj, "basis")
1046
+ var origin_d: Dictionary = _json_object(obj, "origin")
1047
+ var basis: Basis = Basis.IDENTITY
1048
+ if basis_d.has("x"):
1049
+ basis = _json_basis(basis_d)
1050
+ var origin := Vector3(_json_float(origin_d, "x"), _json_float(origin_d, "y"), _json_float(origin_d, "z"))
1051
+ return Transform3D(basis, origin)
1052
+ TYPE_TRANSFORM2D:
1053
+ if obj.has("x") and obj.has("y") and obj.has("origin"):
1054
+ var tx: Dictionary = _json_object(obj, "x")
1055
+ var ty: Dictionary = _json_object(obj, "y")
1056
+ var t_origin: Dictionary = _json_object(obj, "origin")
1057
+ return Transform2D(
1058
+ Vector2(_json_float(tx, "x"), _json_float(tx, "y")),
1059
+ Vector2(_json_float(ty, "x"), _json_float(ty, "y")),
1060
+ Vector2(_json_float(t_origin, "x"), _json_float(t_origin, "y"))
1061
+ )
1062
+ TYPE_BOOL:
1063
+ return _variant_to_bool(value)
1064
+ TYPE_INT:
1065
+ return _variant_to_int(value)
1066
+ TYPE_FLOAT:
1067
+ return _variant_to_float(value)
1068
+ TYPE_STRING:
1069
+ return str(value)
1070
+ TYPE_NODE_PATH:
1071
+ return NodePath(str(value))
1072
+ TYPE_OBJECT:
1073
+ if text.begins_with("res://"):
1074
+ if ResourceLoader.exists(text):
1075
+ var res := load(text)
1076
+ if res != null:
1077
+ return res
1078
+ printerr("Failed to load resource from path: " + text)
1079
+ return value
1080
+ return value
1081
+
1082
+ # Helper: Build a Basis from a JSON object holding x/y/z column objects
1083
+ func _json_basis(source: Dictionary) -> Basis:
1084
+ var bx: Dictionary = _json_object(source, "x")
1085
+ var by: Dictionary = _json_object(source, "y")
1086
+ var bz: Dictionary = _json_object(source, "z")
1087
+ return Basis(
1088
+ Vector3(_json_float(bx, "x"), _json_float(bx, "y"), _json_float(bx, "z")),
1089
+ Vector3(_json_float(by, "x"), _json_float(by, "y"), _json_float(by, "z")),
1090
+ Vector3(_json_float(bz, "x"), _json_float(bz, "y"), _json_float(bz, "z"))
1091
+ )
1092
+
1093
+ # Helper: Safe variant-to-string for scene reading
1094
+ func _variant_to_string(value: Variant) -> String:
1095
+ if value == null:
1096
+ return "null"
1097
+ if value is String:
1098
+ return value
1099
+ if value is bool:
1100
+ return "true" if value else "false"
1101
+ if value is NodePath:
1102
+ return str(value)
1103
+ return str(value)
1104
+
1105
+ # Read a scene file and return its full node tree as JSON
1106
+ func read_scene(params: Dictionary) -> OperationResult:
1107
+ if not params.has("scene_path"):
1108
+ return _fail("scene_path is required")
1109
+
1110
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
1111
+ log_info("Reading scene: " + full_scene_path)
1112
+
1113
+ if not FileAccess.file_exists(full_scene_path):
1114
+ return _fail("Scene file does not exist at: " + full_scene_path)
1115
+
1116
+ var scene := load(full_scene_path) as PackedScene
1117
+ if not scene:
1118
+ # A scene that references missing external resources still has readable
1119
+ # text, so fall back to the raw file rather than failing the operation.
1120
+ var f := FileAccess.open(full_scene_path, FileAccess.READ)
1121
+ if f:
1122
+ var raw_content: String = f.get_as_text()
1123
+ f.close()
1124
+ _emit_output("SCENE_JSON_START")
1125
+ _emit_output(JSON.stringify({"error": "Failed to instantiate scene, returning raw text", "raw": raw_content.substr(0, 4096)}))
1126
+ _emit_output("SCENE_JSON_END")
1127
+ return _ok()
1128
+ return _fail("Failed to load scene: " + full_scene_path, PackedStringArray([
1129
+ "The scene may reference missing external resources.",
1130
+ ]))
1131
+
1132
+ var scene_root := scene.instantiate()
1133
+ if scene_root == null:
1134
+ return _fail("Failed to instantiate scene: " + full_scene_path)
1135
+
1136
+ var tree_data: Dictionary = _walk_scene_tree(scene_root)
1137
+
1138
+ # Output as JSON for the TypeScript side to parse
1139
+ _emit_output("SCENE_JSON_START")
1140
+ _emit_output(JSON.stringify(tree_data))
1141
+ _emit_output("SCENE_JSON_END")
1142
+
1143
+ # queue_free() is never processed under --script before quit(), so free
1144
+ # the tree immediately.
1145
+ scene_root.free()
1146
+ return _ok()
1147
+
1148
+ func _walk_scene_tree(node: Node) -> Dictionary:
1149
+ var info: Dictionary = {
1150
+ "name": node.name,
1151
+ "type": node.get_class(),
1152
+ }
1153
+
1154
+ # Include script path if attached
1155
+ var node_script: Script = node.get_script()
1156
+ if node_script != null:
1157
+ info["script"] = node_script.resource_path
1158
+
1159
+ # Collect non-default properties
1160
+ var props: Dictionary = {}
1161
+ for prop in node.get_property_list():
1162
+ var prop_name: String = prop["name"]
1163
+ var usage: int = prop.get("usage", 0)
1164
+ # Only include editor-visible, storage properties
1165
+ if usage & PROPERTY_USAGE_EDITOR and usage & PROPERTY_USAGE_STORAGE:
1166
+ var value: Variant = node.get(prop_name)
1167
+ if value != null:
1168
+ props[prop_name] = _variant_to_string(value)
1169
+
1170
+ if props.size() > 0:
1171
+ info["properties"] = props
1172
+
1173
+ # Include groups
1174
+ var groups: Array[StringName] = node.get_groups()
1175
+ if groups.size() > 0:
1176
+ var group_names: Array[String] = []
1177
+ for g in groups:
1178
+ group_names.append(String(g))
1179
+ info["groups"] = group_names
1180
+
1181
+ # Recurse into children
1182
+ var children_arr: Array[Dictionary] = []
1183
+ for child in node.get_children():
1184
+ children_arr.append(_walk_scene_tree(child))
1185
+
1186
+ if children_arr.size() > 0:
1187
+ info["children"] = children_arr
1188
+
1189
+ return info
1190
+
1191
+ # Modify a node's properties in a scene file
1192
+ func modify_node(params: Dictionary) -> OperationResult:
1193
+ if not params.has("scene_path") or not params.has("node_path") or not params.has("properties"):
1194
+ return _fail("scene_path, node_path, and properties are required")
1195
+
1196
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
1197
+ log_info("Modifying node in scene: " + full_scene_path)
1198
+
1199
+ var opened := _open_scene(full_scene_path)
1200
+ if not opened.is_valid():
1201
+ return _failed(opened.errors)
1202
+
1203
+ var node_path: String = _param_string(params, "node_path")
1204
+ var target := _resolve_scene_node(opened.root, node_path)
1205
+ if target == null:
1206
+ return _fail("Node not found: " + node_path)
1207
+
1208
+ _apply_properties(target, _param_dictionary(params, "properties"))
1209
+
1210
+ var save_result := _save_scene_root(opened.root, full_scene_path)
1211
+ if not save_result.ok:
1212
+ return save_result
1213
+
1214
+ _emit_output("Node modified successfully in: " + full_scene_path)
1215
+ return _ok()
1216
+
1217
+ # Remove a node from a scene file
1218
+ func remove_node(params: Dictionary) -> OperationResult:
1219
+ if not params.has("scene_path") or not params.has("node_path"):
1220
+ return _fail("scene_path and node_path are required")
1221
+
1222
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
1223
+ log_info("Removing node from scene: " + full_scene_path)
1224
+
1225
+ var opened := _open_scene(full_scene_path)
1226
+ if not opened.is_valid():
1227
+ return _failed(opened.errors)
1228
+ var scene_root: Node = opened.root
1229
+
1230
+ var node_path: String = _param_string(params, "node_path")
1231
+ var target := _resolve_scene_node(scene_root, node_path)
1232
+ if target == null:
1233
+ return _fail("Node not found: " + node_path)
1234
+
1235
+ if target == scene_root:
1236
+ return _fail("Cannot remove the root node of a scene")
1237
+
1238
+ var removed_name: String = String(target.name)
1239
+ target.get_parent().remove_child(target)
1240
+ # queue_free() is never processed under --script before quit(); free the
1241
+ # detached subtree immediately so it cannot leak at exit.
1242
+ target.free()
1243
+
1244
+ var save_result := _save_scene_root(scene_root, full_scene_path)
1245
+ if not save_result.ok:
1246
+ return save_result
1247
+
1248
+ _emit_output("Node '" + removed_name + "' removed successfully from: " + full_scene_path)
1249
+ return _ok()
1250
+
1251
+ # Attach a script to a node in a scene file
1252
+ func attach_script(params: Dictionary) -> OperationResult:
1253
+ if not params.has("scene_path") or not params.has("node_path") or not params.has("script_path"):
1254
+ return _fail("scene_path, node_path, and script_path are required")
1255
+
1256
+ var full_scene_path: String = _res_path(_param_string(params, "scene_path"))
1257
+ var full_script_path: String = _res_path(_param_string(params, "script_path"))
1258
+ log_info("Attaching script " + full_script_path + " to node in scene: " + full_scene_path)
1259
+
1260
+ if not FileAccess.file_exists(full_script_path):
1261
+ return _fail("Script file does not exist at: " + full_script_path)
1262
+
1263
+ var opened := _open_scene(full_scene_path)
1264
+ if not opened.is_valid():
1265
+ return _failed(opened.errors)
1266
+
1267
+ var node_path: String = _param_string(params, "node_path")
1268
+ var target := _resolve_scene_node(opened.root, node_path)
1269
+ if target == null:
1270
+ return _fail("Node not found: " + node_path)
1271
+
1272
+ var script := load(full_script_path) as Script
1273
+ if script == null:
1274
+ return _fail("Failed to load script: " + full_script_path)
1275
+
1276
+ target.set_script(script)
1277
+
1278
+ var save_result := _save_scene_root(opened.root, full_scene_path)
1279
+ if not save_result.ok:
1280
+ return save_result
1281
+
1282
+ _emit_output("Script '" + full_script_path + "' attached successfully to node in: " + full_scene_path)
1283
+ return _ok()
1284
+
1285
+ # Create a resource file (.tres)
1286
+ func create_resource(params: Dictionary) -> OperationResult:
1287
+ if not params.has("resource_type") or not params.has("resource_path"):
1288
+ return _fail("resource_type and resource_path are required")
1289
+
1290
+ var resource_type: String = _param_string(params, "resource_type")
1291
+ var full_resource_path: String = _res_path(_param_string(params, "resource_path"))
1292
+ log_info("Creating resource of type " + resource_type + " at: " + full_resource_path)
1293
+
1294
+ if not ClassDB.class_exists(resource_type):
1295
+ return _fail("Unknown resource type: " + resource_type, PackedStringArray([
1296
+ "Must be a valid Godot class name (e.g., StandardMaterial3D, AudioStreamPlayer, Theme)",
1297
+ ]))
1298
+
1299
+ if not ClassDB.can_instantiate(resource_type):
1300
+ return _fail("Cannot instantiate resource type: " + resource_type)
1301
+
1302
+ var instance: Object = ClassDB.instantiate(resource_type)
1303
+ if instance == null:
1304
+ return _fail("Failed to instantiate resource of type: " + resource_type)
1305
+
1306
+ var resource := instance as Resource
1307
+ if resource == null:
1308
+ return _fail("Type " + resource_type + " is not a Resource subclass")
1309
+
1310
+ _apply_properties(resource, _param_dictionary(params, "properties"))
1311
+
1312
+ var save_result := _save_resource(resource, full_resource_path, "resource")
1313
+ if not save_result.ok:
1314
+ return save_result
1315
+
1316
+ _emit_output("Resource created successfully at: " + full_resource_path)
1317
+ return _ok()
1318
+
1319
+
1320
+ func manage_resource(params: Dictionary) -> OperationResult:
1321
+ var full_path: String = _res_path(_param_string(params, "resource_path"))
1322
+ var action: String = _param_string(params, "action", "read")
1323
+
1324
+ if action != "read" and action != "modify":
1325
+ return _fail("Unknown manage_resource action: " + action, PackedStringArray([
1326
+ "Allowed actions: read, modify",
1327
+ ]))
1328
+
1329
+ var opened := _open_resource(full_path, "Resource")
1330
+ if not opened.is_valid():
1331
+ return _failed(opened.errors)
1332
+ var resource: Resource = opened.resource
1333
+
1334
+ if action == "read":
1335
+ var props: Dictionary = {}
1336
+ for prop in resource.get_property_list():
1337
+ var usage: int = prop.get("usage", 0)
1338
+ if usage & PROPERTY_USAGE_STORAGE:
1339
+ var prop_name: String = prop["name"]
1340
+ props[prop_name] = str(resource.get(prop_name))
1341
+ _emit_output("RESOURCE_JSON_START")
1342
+ _emit_output(JSON.stringify({"type": resource.get_class(), "path": full_path, "properties": props}))
1343
+ _emit_output("RESOURCE_JSON_END")
1344
+ return _ok()
1345
+
1346
+ _apply_properties(resource, _param_dictionary(params, "properties"))
1347
+
1348
+ var save_result := _save_resource(resource, full_path, "resource")
1349
+ if not save_result.ok:
1350
+ return save_result
1351
+
1352
+ _emit_output("Resource modified: " + full_path)
1353
+ return _ok()
1354
+
1355
+
1356
+ func manage_scene_signals(params: Dictionary) -> OperationResult:
1357
+ var full_path: String = _res_path(_param_string(params, "scene_path"))
1358
+ var action: String = _param_string(params, "action", "list")
1359
+
1360
+ if not FileAccess.file_exists(full_path):
1361
+ return _fail("Scene not found: " + full_path)
1362
+
1363
+ var content: String = FileAccess.get_file_as_string(full_path)
1364
+
1365
+ if action == "list":
1366
+ var connections: Array[String] = []
1367
+ var lines: PackedStringArray = content.split("\n")
1368
+ for line in lines:
1369
+ if line.begins_with("[connection"):
1370
+ connections.append(line.strip_edges())
1371
+ _emit_output("SIGNALS_JSON_START")
1372
+ _emit_output(JSON.stringify({"connections": connections}))
1373
+ _emit_output("SIGNALS_JSON_END")
1374
+ elif action == "add":
1375
+ var signal_name: String = _param_string(params, "signal_name")
1376
+ var source_path: String = _param_string(params, "source_path", ".")
1377
+ var target_path: String = _param_string(params, "target_path", ".")
1378
+ var method: String = _param_string(params, "method")
1379
+ var conn_line: String = '[connection signal="%s" from="%s" to="%s" method="%s"]' % [signal_name, source_path, target_path, method]
1380
+ content += "\n" + conn_line + "\n"
1381
+ var file := FileAccess.open(full_path, FileAccess.WRITE)
1382
+ if file == null:
1383
+ return _fail("Failed to open scene for writing: " + full_path + ", error: " + str(FileAccess.get_open_error()))
1384
+ @warning_ignore("return_value_discarded")
1385
+ file.store_string(content)
1386
+ file.close()
1387
+ _emit_output("Signal connection added: " + conn_line)
1388
+ elif action == "remove":
1389
+ var signal_name: String = _param_string(params, "signal_name")
1390
+ var lines: PackedStringArray = content.split("\n")
1391
+ var new_lines := PackedStringArray()
1392
+ for line in lines:
1393
+ if not (line.begins_with("[connection") and signal_name in line):
1394
+ @warning_ignore("return_value_discarded")
1395
+ new_lines.append(line)
1396
+ var file := FileAccess.open(full_path, FileAccess.WRITE)
1397
+ if file == null:
1398
+ return _fail("Failed to open scene for writing: " + full_path + ", error: " + str(FileAccess.get_open_error()))
1399
+ @warning_ignore("return_value_discarded")
1400
+ file.store_string("\n".join(new_lines))
1401
+ file.close()
1402
+ _emit_output("Signal connections for '%s' removed" % signal_name)
1403
+ else:
1404
+ return _fail("Unknown manage_scene_signals action: " + action, PackedStringArray([
1405
+ "Allowed actions: list, add, remove",
1406
+ ]))
1407
+
1408
+ return _ok()
1409
+
1410
+
1411
+ func manage_theme_resource(params: Dictionary) -> OperationResult:
1412
+ var full_path: String = _res_path(_param_string(params, "resource_path"))
1413
+ var action: String = _param_string(params, "action", "read")
1414
+ var properties: Dictionary = _param_dictionary(params, "properties")
1415
+
1416
+ if action == "create":
1417
+ var new_theme := Theme.new()
1418
+ for key: String in properties:
1419
+ new_theme.set(key, properties[key])
1420
+
1421
+ var create_result := _save_resource(new_theme, full_path, "theme")
1422
+ if not create_result.ok:
1423
+ return create_result
1424
+
1425
+ _emit_output("Theme created at: " + full_path)
1426
+ return _ok()
1427
+
1428
+ if action != "read" and action != "modify":
1429
+ return _fail("Unknown manage_theme_resource action: " + action, PackedStringArray([
1430
+ "Allowed actions: create, read, modify",
1431
+ ]))
1432
+
1433
+ var opened := _open_resource(full_path, "Theme")
1434
+ if not opened.is_valid():
1435
+ return _failed(opened.errors)
1436
+ var theme: Resource = opened.resource
1437
+
1438
+ if action == "read":
1439
+ _emit_output("THEME_JSON_START")
1440
+ _emit_output(JSON.stringify({"type": theme.get_class(), "path": full_path}))
1441
+ _emit_output("THEME_JSON_END")
1442
+ return _ok()
1443
+
1444
+ for key: String in properties:
1445
+ theme.set(key, properties[key])
1446
+
1447
+ var save_result := _save_resource(theme, full_path, "theme")
1448
+ if not save_result.ok:
1449
+ return save_result
1450
+
1451
+ _emit_output("Theme modified: " + full_path)
1452
+ return _ok()
1453
+
1454
+
1455
+ func manage_scene_structure(params: Dictionary) -> OperationResult:
1456
+ var full_path: String = _res_path(_param_string(params, "scene_path"))
1457
+ var action: String = _param_string(params, "action", "rename")
1458
+ var node_path_str: String = _param_string(params, "node_path")
1459
+
1460
+ var opened := _open_scene(full_path)
1461
+ if not opened.is_valid():
1462
+ return _failed(opened.errors)
1463
+ var scene_root: Node = opened.root
1464
+
1465
+ var target := _resolve_scene_node(scene_root, node_path_str)
1466
+ if target == null:
1467
+ return _fail("Node not found: " + node_path_str)
1468
+
1469
+ if action == "rename":
1470
+ var new_name: String = _param_string(params, "new_name")
1471
+ if new_name.is_empty():
1472
+ return _fail("new_name is required for rename")
1473
+ target.name = new_name
1474
+ _emit_output("Node renamed to '%s'" % target.name)
1475
+ elif action == "duplicate":
1476
+ if target == scene_root:
1477
+ return _fail("Cannot duplicate the root node")
1478
+ var dup := target.duplicate()
1479
+ target.get_parent().add_child(dup, true)
1480
+ _set_owner_recursive(dup, scene_root)
1481
+ _emit_output("Node duplicated: %s (as '%s')" % [node_path_str, dup.name])
1482
+ elif action == "move":
1483
+ var new_parent_path: String = _param_string(params, "new_parent_path")
1484
+ if new_parent_path.is_empty():
1485
+ return _fail("new_parent_path is required for move")
1486
+ if target == scene_root:
1487
+ return _fail("Cannot move the root node")
1488
+ var new_parent := _resolve_scene_node(scene_root, new_parent_path)
1489
+ if new_parent == null:
1490
+ return _fail("New parent not found: " + new_parent_path)
1491
+ if new_parent == target or _is_ancestor(target, new_parent):
1492
+ return _fail("Cannot move a node into itself or one of its descendants")
1493
+ target.get_parent().remove_child(target)
1494
+ # Descendants keep their owner across remove_child, and add_child warns
1495
+ # that the pending owner would be inconsistent; clear owners first and
1496
+ # reassign them once the subtree is in place.
1497
+ _clear_owner_recursive(target)
1498
+ new_parent.add_child(target, true)
1499
+ _set_owner_recursive(target, scene_root)
1500
+ _emit_output("Node moved: %s -> parent %s (as '%s')" % [node_path_str, new_parent_path, target.name])
1501
+ else:
1502
+ return _fail("Unknown manage_scene_structure action: " + action, PackedStringArray([
1503
+ "Allowed actions: rename, duplicate, move",
1504
+ ]))
1505
+
1506
+ var save_result := _save_scene_root(scene_root, full_path)
1507
+ if not save_result.ok:
1508
+ return save_result
1509
+
1510
+ _emit_output("Scene structure saved: " + full_path)
1511
+ return _ok()
1512
+
1513
+
1514
+ func _clear_owner_recursive(node: Node) -> void:
1515
+ node.owner = null
1516
+ for c in node.get_children():
1517
+ _clear_owner_recursive(c)
1518
+
1519
+
1520
+ func _set_owner_recursive(node: Node, owner_root: Node) -> void:
1521
+ node.owner = owner_root
1522
+ if node.scene_file_path != "":
1523
+ return
1524
+ for c in node.get_children():
1525
+ _set_owner_recursive(c, owner_root)
1526
+
1527
+
1528
+ func _is_ancestor(node: Node, maybe_descendant: Node) -> bool:
1529
+ var n := maybe_descendant.get_parent()
1530
+ while n != null:
1531
+ if n == node:
1532
+ return true
1533
+ n = n.get_parent()
1534
+ return false