godot-mcp-runtime 2.3.0 → 3.1.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 (59) hide show
  1. package/README.md +46 -132
  2. package/dist/dispatch.d.ts +1 -11
  3. package/dist/dispatch.d.ts.map +1 -1
  4. package/dist/dispatch.js +32 -33
  5. package/dist/dispatch.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +12 -10
  9. package/dist/index.js.map +1 -1
  10. package/dist/scripts/godot_operations.gd +268 -382
  11. package/dist/scripts/mcp_bridge.gd +206 -44
  12. package/dist/tools/autoload-tools.d.ts +51 -0
  13. package/dist/tools/autoload-tools.d.ts.map +1 -0
  14. package/dist/tools/autoload-tools.js +191 -0
  15. package/dist/tools/autoload-tools.js.map +1 -0
  16. package/dist/tools/node-tools.d.ts +9 -78
  17. package/dist/tools/node-tools.d.ts.map +1 -1
  18. package/dist/tools/node-tools.js +188 -312
  19. package/dist/tools/node-tools.js.map +1 -1
  20. package/dist/tools/project-tools.d.ts +0 -168
  21. package/dist/tools/project-tools.d.ts.map +1 -1
  22. package/dist/tools/project-tools.js +191 -1240
  23. package/dist/tools/project-tools.js.map +1 -1
  24. package/dist/tools/runtime-tools.d.ts +108 -0
  25. package/dist/tools/runtime-tools.d.ts.map +1 -0
  26. package/dist/tools/runtime-tools.js +994 -0
  27. package/dist/tools/runtime-tools.js.map +1 -0
  28. package/dist/tools/scene-tools.d.ts +6 -48
  29. package/dist/tools/scene-tools.d.ts.map +1 -1
  30. package/dist/tools/scene-tools.js +76 -212
  31. package/dist/tools/scene-tools.js.map +1 -1
  32. package/dist/tools/validate-tools.d.ts.map +1 -1
  33. package/dist/tools/validate-tools.js +115 -51
  34. package/dist/tools/validate-tools.js.map +1 -1
  35. package/dist/utils/autoload-ini.d.ts +38 -0
  36. package/dist/utils/autoload-ini.d.ts.map +1 -0
  37. package/dist/utils/autoload-ini.js +124 -0
  38. package/dist/utils/autoload-ini.js.map +1 -0
  39. package/dist/utils/bridge-manager.d.ts +46 -0
  40. package/dist/utils/bridge-manager.d.ts.map +1 -0
  41. package/dist/utils/bridge-manager.js +186 -0
  42. package/dist/utils/bridge-manager.js.map +1 -0
  43. package/dist/utils/bridge-protocol.d.ts +37 -0
  44. package/dist/utils/bridge-protocol.d.ts.map +1 -0
  45. package/dist/utils/bridge-protocol.js +78 -0
  46. package/dist/utils/bridge-protocol.js.map +1 -0
  47. package/dist/utils/godot-runner.d.ts +102 -16
  48. package/dist/utils/godot-runner.d.ts.map +1 -1
  49. package/dist/utils/godot-runner.js +497 -284
  50. package/dist/utils/godot-runner.js.map +1 -1
  51. package/dist/utils/handler-helpers.d.ts +34 -0
  52. package/dist/utils/handler-helpers.d.ts.map +1 -0
  53. package/dist/utils/handler-helpers.js +55 -0
  54. package/dist/utils/handler-helpers.js.map +1 -0
  55. package/dist/utils/logger.d.ts +4 -0
  56. package/dist/utils/logger.d.ts.map +1 -0
  57. package/dist/utils/logger.js +11 -0
  58. package/dist/utils/logger.js.map +1 -0
  59. package/package.json +8 -4
@@ -10,11 +10,15 @@ func _init():
10
10
  # Check for debug flag
11
11
  debug_mode = "--debug-godot" in args
12
12
 
13
+ # SceneTree.quit(n) only schedules a quit for end-of-frame in Godot 4.
14
+ # Every quit(1) must be followed by `return` to halt the failing path,
15
+ # otherwise control falls through into success-print + scene save.
13
16
  # Find the script argument and determine the positions of operation and params
14
17
  var script_index = args.find("--script")
15
18
  if script_index == -1:
16
19
  log_error("Could not find --script argument")
17
20
  quit(1)
21
+ return
18
22
 
19
23
  var operation_index = script_index + 2
20
24
  var params_index = script_index + 3
@@ -23,6 +27,7 @@ func _init():
23
27
  log_error("Usage: godot --headless --script godot_operations.gd <operation> <json_params>")
24
28
  log_error("Not enough command-line arguments provided.")
25
29
  quit(1)
30
+ return
26
31
 
27
32
  log_debug("All arguments: " + str(args))
28
33
 
@@ -42,10 +47,12 @@ func _init():
42
47
  log_error("Failed to parse JSON parameters: " + params_json)
43
48
  log_error("JSON Error: " + json.get_error_message() + " at line " + str(json.get_error_line()))
44
49
  quit(1)
50
+ return
45
51
 
46
52
  if not params:
47
53
  log_error("Failed to parse JSON parameters: " + params_json)
48
54
  quit(1)
55
+ return
49
56
 
50
57
  log_info("Executing operation: " + operation)
51
58
 
@@ -61,19 +68,13 @@ func _init():
61
68
  export_mesh_library(params)
62
69
  "save_scene":
63
70
  save_scene(params)
64
- "get_uid":
65
- get_uid(params)
66
- "resave_resources":
67
- resave_resources(params)
68
- # New node operations
69
- "delete_node":
70
- delete_node(params)
71
- "set_node_property":
72
- set_node_property(params)
71
+ # Node operations (always-array)
72
+ "delete_nodes":
73
+ delete_nodes(params)
74
+ "set_node_properties":
75
+ set_node_properties(params)
73
76
  "get_node_properties":
74
77
  get_node_properties(params)
75
- "list_nodes":
76
- list_nodes(params)
77
78
  "get_scene_tree":
78
79
  get_scene_tree(params)
79
80
  "attach_script":
@@ -93,15 +94,13 @@ func _init():
93
94
  validate_batch(params)
94
95
  "batch_scene_operations":
95
96
  batch_scene_operations(params)
96
- "batch_set_node_properties":
97
- batch_set_node_properties(params)
98
- "batch_get_node_properties":
99
- batch_get_node_properties(params)
100
97
  _:
101
98
  log_error("Unknown operation: " + operation)
102
99
  quit(1)
100
+ return
103
101
 
104
102
  quit()
103
+ return
105
104
 
106
105
  # Logging functions
107
106
  func log_debug(message):
@@ -220,14 +219,22 @@ func load_scene_instance(scene_path: String):
220
219
 
221
220
  return instance
222
221
 
223
- # Helper to find a node by path
222
+ # Helper to find a node by path. Accepts "root", ".", "" (all → scene_root),
223
+ # the actual scene root's name (e.g. "Main"), or a path with either as the first
224
+ # segment (e.g. "root/Button" or "Main/Button"). Bare paths ("Button") resolve
225
+ # normally via get_node_or_null.
224
226
  func find_node_by_path(scene_root: Node, node_path: String) -> Node:
225
- if node_path == "root" or node_path.is_empty():
227
+ if node_path == "" or node_path == "." or node_path == "root":
228
+ return scene_root
229
+ if node_path == String(scene_root.name):
226
230
  return scene_root
227
231
 
228
232
  var path = node_path
229
- if path.begins_with("root/"):
230
- path = path.substr(5)
233
+ var first_slash = path.find("/")
234
+ if first_slash != -1:
235
+ var first_segment = path.substr(0, first_slash)
236
+ if first_segment == "root" or first_segment == String(scene_root.name):
237
+ path = path.substr(first_slash + 1)
231
238
 
232
239
  if path.is_empty():
233
240
  return scene_root
@@ -252,6 +259,20 @@ func save_scene_to_path(scene_root: Node, save_path: String) -> bool:
252
259
 
253
260
  return true
254
261
 
262
+ # Ensure the parent directory of a res:// path exists, creating it recursively
263
+ # if needed. Returns true on success or when the directory already exists.
264
+ func _ensure_res_dir(full_res_path: String) -> bool:
265
+ var dir_path = full_res_path.get_base_dir()
266
+ if dir_path == "res://" or dir_path.is_empty():
267
+ return true
268
+ var dir = DirAccess.open("res://")
269
+ if not dir:
270
+ return false
271
+ var relative_dir = dir_path.substr(6) if dir_path.begins_with("res://") else dir_path
272
+ if relative_dir.is_empty() or dir.dir_exists(relative_dir):
273
+ return true
274
+ return dir.make_dir_recursive(relative_dir) == OK
275
+
255
276
  # Create a new scene with a specified root node type
256
277
  func create_scene(params):
257
278
  printerr("Creating scene: " + params.scene_path)
@@ -268,66 +289,94 @@ func create_scene(params):
268
289
  if not scene_root:
269
290
  log_error("Failed to instantiate node of type: " + root_node_type)
270
291
  quit(1)
292
+ return
271
293
 
272
294
  scene_root.name = "root"
273
295
  scene_root.owner = scene_root
274
296
 
275
- # Ensure directory exists
276
- var scene_dir = full_scene_path.get_base_dir()
277
- if scene_dir != "res://" and not scene_dir.is_empty():
278
- var dir = DirAccess.open("res://")
279
- if dir:
280
- var relative_dir = scene_dir.substr(6) if scene_dir.begins_with("res://") else scene_dir
281
- if not relative_dir.is_empty() and not dir.dir_exists(relative_dir):
282
- var make_error = dir.make_dir_recursive(relative_dir)
283
- if make_error != OK:
284
- log_error("Failed to create directory: " + relative_dir)
285
- quit(1)
297
+ if not _ensure_res_dir(full_scene_path):
298
+ log_error("Failed to create directory for scene: " + full_scene_path)
299
+ quit(1)
300
+ return
286
301
 
287
302
  if save_scene_to_path(scene_root, full_scene_path):
288
303
  print("Scene created successfully at: " + params.scene_path)
289
304
  else:
290
305
  log_error("Failed to create scene: " + params.scene_path)
291
306
  quit(1)
307
+ return
292
308
 
293
309
  # Add a node to an existing scene
310
+ # Apply an add_node mutation without saving. Shared by standalone add_node
311
+ # and batch_scene_operations so both paths validate identically.
312
+ # Returns {"ok": bool, "error": String}; error is empty on success.
313
+ func _apply_add_node(scene_root: Node, op: Dictionary) -> Dictionary:
314
+ var parent_path = "root"
315
+ if op.has("parent_node_path"):
316
+ parent_path = op.parent_node_path
317
+ var parent = find_node_by_path(scene_root, parent_path)
318
+ if not parent:
319
+ return {"ok": false, "error": "Parent node not found: " + parent_path}
320
+ if not op.has("node_type") or op.node_type == "":
321
+ return {"ok": false, "error": "node_type is required for add_node"}
322
+ if not op.has("node_name") or op.node_name == "":
323
+ return {"ok": false, "error": "node_name is required for add_node"}
324
+ var new_node = instantiate_class(op.node_type)
325
+ if not new_node:
326
+ return {"ok": false, "error": "Failed to instantiate node of type: " + op.node_type}
327
+ new_node.name = op.node_name
328
+ if op.has("properties"):
329
+ for property in op.properties:
330
+ new_node.set(property, _coerce_property_value(op.properties[property]))
331
+ parent.add_child(new_node)
332
+ new_node.owner = scene_root
333
+ return {"ok": true, "error": ""}
334
+
335
+ # Apply a load_sprite mutation without saving. Shared by standalone load_sprite
336
+ # and batch_scene_operations.
337
+ func _apply_load_sprite(scene_root: Node, op: Dictionary) -> Dictionary:
338
+ if not op.has("node_path") or op.node_path == "":
339
+ return {"ok": false, "error": "node_path is required for load_sprite"}
340
+ if not op.has("texture_path") or op.texture_path == "":
341
+ return {"ok": false, "error": "texture_path is required for load_sprite"}
342
+ var sprite_node = find_node_by_path(scene_root, op.node_path)
343
+ if not sprite_node:
344
+ return {"ok": false, "error": "Node not found: " + op.node_path}
345
+ if not (sprite_node is Sprite2D or sprite_node is Sprite3D or sprite_node is TextureRect):
346
+ return {"ok": false, "error": "Node is not a sprite-compatible type: " + sprite_node.get_class()}
347
+ var full_texture_path = normalize_scene_path(op.texture_path)
348
+ var texture = load(full_texture_path)
349
+ if not texture:
350
+ return {"ok": false, "error": "Failed to load texture: " + full_texture_path}
351
+ if not (texture is Texture2D):
352
+ return {"ok": false, "error": "Loaded resource is not a Texture2D: " + full_texture_path}
353
+ # A texture without a resource_path is a runtime-only object — PackedScene.pack()
354
+ # cannot serialize it, so the assignment would silently vanish on save.
355
+ if texture.resource_path == "":
356
+ return {"ok": false, "error": "Texture has no resource_path — likely not imported. Open project in Godot editor once, or run 'godot --headless --editor --quit' to import assets."}
357
+ sprite_node.texture = texture
358
+ return {"ok": true, "error": ""}
359
+
294
360
  func add_node(params):
295
361
  printerr("Adding node to scene: " + params.scene_path)
296
362
 
297
363
  var scene_root = load_scene_instance(params.scene_path)
298
364
  if not scene_root:
299
365
  quit(1)
366
+ return
300
367
 
301
- var parent_path = "root"
302
- if params.has("parent_node_path"):
303
- parent_path = params.parent_node_path
304
-
305
- var parent = find_node_by_path(scene_root, parent_path)
306
- if not parent:
307
- log_error("Parent node not found: " + parent_path)
308
- quit(1)
309
-
310
- var new_node = instantiate_class(params.node_type)
311
- if not new_node:
312
- log_error("Failed to instantiate node of type: " + params.node_type)
368
+ var result = _apply_add_node(scene_root, params)
369
+ if not result.ok:
370
+ log_error(result.error)
313
371
  quit(1)
314
-
315
- new_node.name = params.node_name
316
-
317
- if params.has("properties"):
318
- var properties = params.properties
319
- for property in properties:
320
- log_debug("Setting property: " + property + " = " + str(properties[property]))
321
- new_node.set(property, properties[property])
322
-
323
- parent.add_child(new_node)
324
- new_node.owner = scene_root
372
+ return
325
373
 
326
374
  if save_scene_to_path(scene_root, params.scene_path):
327
375
  print("Node '" + params.node_name + "' of type '" + params.node_type + "' added successfully")
328
376
  else:
329
377
  log_error("Failed to save scene after adding node")
330
378
  quit(1)
379
+ return
331
380
 
332
381
  # Load a sprite into a Sprite2D node
333
382
  func load_sprite(params):
@@ -336,29 +385,20 @@ func load_sprite(params):
336
385
  var scene_root = load_scene_instance(params.scene_path)
337
386
  if not scene_root:
338
387
  quit(1)
388
+ return
339
389
 
340
- var sprite_node = find_node_by_path(scene_root, params.node_path)
341
- if not sprite_node:
342
- log_error("Node not found: " + params.node_path)
343
- quit(1)
344
-
345
- if not (sprite_node is Sprite2D or sprite_node is Sprite3D or sprite_node is TextureRect):
346
- log_error("Node is not a sprite-compatible type: " + sprite_node.get_class())
347
- quit(1)
348
-
349
- var full_texture_path = normalize_scene_path(params.texture_path)
350
- var texture = load(full_texture_path)
351
- if not texture:
352
- log_error("Failed to load texture: " + full_texture_path)
390
+ var result = _apply_load_sprite(scene_root, params)
391
+ if not result.ok:
392
+ log_error(result.error)
353
393
  quit(1)
354
-
355
- sprite_node.texture = texture
394
+ return
356
395
 
357
396
  if save_scene_to_path(scene_root, params.scene_path):
358
397
  print("Sprite loaded successfully with texture: " + params.texture_path)
359
398
  else:
360
399
  log_error("Failed to save scene after loading sprite")
361
400
  quit(1)
401
+ return
362
402
 
363
403
  # Export a scene as a MeshLibrary resource
364
404
  func export_mesh_library(params):
@@ -367,6 +407,7 @@ func export_mesh_library(params):
367
407
  var scene_root = load_scene_instance(params.scene_path)
368
408
  if not scene_root:
369
409
  quit(1)
410
+ return
370
411
 
371
412
  var mesh_library = MeshLibrary.new()
372
413
 
@@ -398,22 +439,17 @@ func export_mesh_library(params):
398
439
  mesh_library.set_item_shapes(item_id, [collision_child.shape])
399
440
  break
400
441
 
401
- if mesh_instance.mesh:
402
- mesh_library.set_item_preview(item_id, mesh_instance.mesh)
442
+ mesh_library.set_item_preview(item_id, mesh_instance.mesh)
403
443
 
404
444
  item_id += 1
405
445
 
406
446
  if item_id > 0:
407
447
  var full_output_path = normalize_scene_path(params.output_path)
408
448
 
409
- # Ensure output directory exists
410
- var output_dir = full_output_path.get_base_dir()
411
- if output_dir != "res://":
412
- var dir = DirAccess.open("res://")
413
- if dir:
414
- var relative_dir = output_dir.substr(6) if output_dir.begins_with("res://") else output_dir
415
- if not relative_dir.is_empty() and not dir.dir_exists(relative_dir):
416
- dir.make_dir_recursive(relative_dir)
449
+ if not _ensure_res_dir(full_output_path):
450
+ log_error("Failed to create directory for MeshLibrary: " + full_output_path)
451
+ quit(1)
452
+ return
417
453
 
418
454
  var error = ResourceSaver.save(mesh_library, full_output_path)
419
455
  if error == OK:
@@ -421,9 +457,11 @@ func export_mesh_library(params):
421
457
  else:
422
458
  log_error("Failed to save MeshLibrary: " + str(error))
423
459
  quit(1)
460
+ return
424
461
  else:
425
462
  log_error("No valid meshes found in the scene")
426
463
  quit(1)
464
+ return
427
465
 
428
466
  # Save changes to a scene file
429
467
  func save_scene(params):
@@ -432,6 +470,7 @@ func save_scene(params):
432
470
  var scene_root = load_scene_instance(params.scene_path)
433
471
  if not scene_root:
434
472
  quit(1)
473
+ return
435
474
 
436
475
  var save_path = params.new_path if params.has("new_path") else params.scene_path
437
476
 
@@ -440,217 +479,109 @@ func save_scene(params):
440
479
  else:
441
480
  log_error("Failed to save scene")
442
481
  quit(1)
443
-
444
- # Find files with a specific extension recursively
445
- func find_files(path, extension):
446
- var files = []
447
- var dir = DirAccess.open(path)
448
-
449
- if dir:
450
- dir.list_dir_begin()
451
- var file_name = dir.get_next()
452
-
453
- while file_name != "":
454
- if dir.current_is_dir() and not file_name.begins_with("."):
455
- files.append_array(find_files(path + file_name + "/", extension))
456
- elif file_name.ends_with(extension):
457
- files.append(path + file_name)
458
-
459
- file_name = dir.get_next()
460
-
461
- return files
462
-
463
- # Get UID for a specific file
464
- func get_uid(params):
465
- if not params.has("file_path"):
466
- log_error("File path is required")
467
- quit(1)
468
-
469
- var file_path = normalize_scene_path(params.file_path)
470
- printerr("Getting UID for file: " + file_path)
471
-
472
- if not FileAccess.file_exists(file_path):
473
- log_error("File does not exist: " + file_path)
474
- quit(1)
475
-
476
- var uid_path = file_path + ".uid"
477
- var f = FileAccess.open(uid_path, FileAccess.READ)
478
-
479
- if f:
480
- var uid_content = f.get_as_text()
481
- f.close()
482
-
483
- var result = {
484
- "file": file_path,
485
- "uid": uid_content.strip_edges(),
486
- "exists": true
487
- }
488
- print(JSON.stringify(result))
489
- else:
490
- var result = {
491
- "file": file_path,
492
- "exists": false,
493
- "message": "UID file does not exist for this file. Use resave_resources to generate UIDs."
494
- }
495
- print(JSON.stringify(result))
496
-
497
- # Resave all resources to update UID references
498
- func resave_resources(params):
499
- printerr("Resaving all resources to update UID references...")
500
-
501
- var project_path = "res://"
502
- if params.has("project_path"):
503
- project_path = params.project_path
504
- if not project_path.begins_with("res://"):
505
- project_path = "res://" + project_path
506
- if not project_path.ends_with("/"):
507
- project_path += "/"
508
-
509
- var scenes = find_files(project_path, ".tscn")
510
- var success_count = 0
511
- var error_count = 0
512
-
513
- for scene_path in scenes:
514
- var scene = load(scene_path)
515
- if scene:
516
- var error = ResourceSaver.save(scene, scene_path)
517
- if error == OK:
518
- success_count += 1
519
- else:
520
- error_count += 1
521
- log_error("Failed to save: " + scene_path)
522
- else:
523
- error_count += 1
524
- log_error("Failed to load: " + scene_path)
525
-
526
- var scripts = find_files(project_path, ".gd") + find_files(project_path, ".shader") + find_files(project_path, ".gdshader")
527
- var generated_uids = 0
528
-
529
- for script_path in scripts:
530
- var uid_path = script_path + ".uid"
531
- var f = FileAccess.open(uid_path, FileAccess.READ)
532
- if not f:
533
- var res = load(script_path)
534
- if res:
535
- var error = ResourceSaver.save(res, script_path)
536
- if error == OK:
537
- generated_uids += 1
538
-
539
- print("Resave operation complete. Scenes: " + str(success_count) + " saved, " + str(error_count) + " errors. UIDs generated: " + str(generated_uids))
482
+ return
540
483
 
541
484
  # ============================================
542
- # NEW NODE OPERATIONS
485
+ # NODE OPERATIONS
543
486
  # ============================================
544
487
 
545
- # Delete a node from a scene
546
- func delete_node(params):
547
- printerr("Deleting node from scene: " + params.scene_path)
548
-
549
- var scene_root = load_scene_instance(params.scene_path)
550
- if not scene_root:
551
- quit(1)
552
-
553
- var node = find_node_by_path(scene_root, params.node_path)
554
- if not node:
555
- log_error("Node not found: " + params.node_path)
556
- quit(1)
557
-
558
- if node == scene_root:
559
- log_error("Cannot delete the root node")
560
- quit(1)
561
-
562
- var parent = node.get_parent()
563
- parent.remove_child(node)
564
- node.queue_free()
565
-
566
- if save_scene_to_path(scene_root, params.scene_path):
567
- print("Node '" + params.node_path + "' deleted successfully")
568
- else:
569
- log_error("Failed to save scene after deleting node")
570
- quit(1)
571
-
572
- # Update a single property on a node
573
- func set_node_property(params):
574
- printerr("Updating node property in scene: " + params.scene_path)
488
+ # Delete one or more nodes from a scene (saves once)
489
+ func delete_nodes(params):
490
+ printerr("Deleting nodes from scene: " + params.scene_path)
575
491
 
576
492
  var scene_root = load_scene_instance(params.scene_path)
577
493
  if not scene_root:
578
494
  quit(1)
495
+ return
579
496
 
580
- var node = find_node_by_path(scene_root, params.node_path)
581
- if not node:
582
- log_error("Node not found: " + params.node_path)
583
- quit(1)
584
-
585
- var property_name = params.property
586
- var property_value = _coerce_property_value(params.value)
587
-
588
- log_debug("Setting property '" + property_name + "' to: " + str(property_value))
589
-
590
- node.set(property_name, property_value)
497
+ var node_paths: Array = params.node_paths
498
+ var results: Array = []
499
+ var any_deleted := false
591
500
 
592
- if save_scene_to_path(scene_root, params.scene_path):
593
- print("Property '" + property_name + "' updated successfully on node '" + params.node_path + "'")
594
- else:
595
- log_error("Failed to save scene after updating property")
596
- quit(1)
501
+ for node_path in node_paths:
502
+ var entry = {"nodePath": node_path}
503
+ var node = find_node_by_path(scene_root, node_path)
504
+ if not node:
505
+ entry["error"] = "Node not found: " + node_path
506
+ elif node == scene_root:
507
+ entry["error"] = "Cannot delete the root node"
508
+ else:
509
+ var parent = node.get_parent()
510
+ parent.remove_child(node)
511
+ node.queue_free()
512
+ entry["success"] = true
513
+ any_deleted = true
514
+ results.append(entry)
515
+
516
+ if any_deleted:
517
+ if not save_scene_to_path(scene_root, params.scene_path):
518
+ print(JSON.stringify({"error": "Failed to save scene after deleting nodes", "results": results}))
519
+ return
597
520
 
598
- # Get all properties of a specific node
599
- func get_node_properties(params):
600
- printerr("Getting node properties from scene: " + params.scene_path)
521
+ print(JSON.stringify({"results": results}))
601
522
 
523
+ # Update one or more node properties in a single headless process (saves once)
524
+ func set_node_properties(params: Dictionary) -> void:
602
525
  var scene_root = load_scene_instance(params.scene_path)
603
526
  if not scene_root:
604
- quit(1)
605
-
606
- var node = find_node_by_path(scene_root, params.node_path)
607
- if not node:
608
- log_error("Node not found: " + params.node_path)
609
- quit(1)
527
+ print(JSON.stringify({"error": "Failed to load scene: " + params.scene_path, "results": []}))
528
+ return
610
529
 
611
- var changed_only = params.has("changed_only") and params.changed_only
612
- var properties = _collect_node_properties(node, changed_only)
530
+ var abort_on_error = params.get("abort_on_error", false)
531
+ var results: Array = []
532
+ var any_set := false
613
533
 
614
- var result = {
615
- "nodePath": params.node_path,
616
- "nodeType": node.get_class(),
617
- "properties": properties
618
- }
534
+ for update in params.updates:
535
+ var result = {"nodePath": update.node_path, "property": update.property}
536
+ var node = find_node_by_path(scene_root, update.node_path)
537
+ if node == null:
538
+ result["error"] = "Node not found: " + update.node_path
539
+ elif not (update.property in node):
540
+ result["error"] = "Property '%s' does not exist on node of type '%s'" % [update.property, node.get_class()]
541
+ else:
542
+ node.set(update.property, _coerce_property_value(update.value))
543
+ result["success"] = true
544
+ any_set = true
545
+ results.append(result)
546
+ if abort_on_error and result.has("error"):
547
+ break
619
548
 
620
- print(JSON.stringify(result))
549
+ if any_set:
550
+ if not save_scene_to_path(scene_root, params.scene_path):
551
+ print(JSON.stringify({"error": "Failed to save scene after updates", "results": results}))
552
+ return
621
553
 
622
- # List all child nodes under a parent
623
- func list_nodes(params):
624
- printerr("Listing nodes in scene: " + params.scene_path)
554
+ print(JSON.stringify({"results": results}))
625
555
 
556
+ # Get properties from one or more nodes in a single headless process (loads scene once)
557
+ func get_node_properties(params: Dictionary) -> void:
626
558
  var scene_root = load_scene_instance(params.scene_path)
627
559
  if not scene_root:
628
- quit(1)
629
-
630
- var parent_path = "root"
631
- if params.has("parent_path"):
632
- parent_path = params.parent_path
560
+ print(JSON.stringify({"error": "Failed to load scene: " + params.scene_path, "results": []}))
561
+ return
633
562
 
634
- var parent = find_node_by_path(scene_root, parent_path)
635
- if not parent:
636
- log_error("Parent node not found: " + parent_path)
637
- quit(1)
563
+ var results: Array = []
564
+ # Class-name → default-instance cache. Reused across all nodes in this call
565
+ # so we don't instantiate a fresh default per node when changed_only is true.
566
+ var defaults_cache: Dictionary = {}
638
567
 
639
- var children = []
640
- for child in parent.get_children():
641
- children.append({
642
- "name": child.name,
643
- "type": child.get_class(),
644
- "childCount": child.get_child_count()
645
- })
568
+ for node_spec in params.nodes:
569
+ var node_path = node_spec.get("node_path", "")
570
+ var changed_only = node_spec.get("changed_only", false)
571
+ var node = find_node_by_path(scene_root, node_path)
572
+ if node == null:
573
+ results.append({"nodePath": node_path, "error": "Node not found"})
574
+ else:
575
+ var props = _collect_node_properties(node, changed_only, defaults_cache)
576
+ results.append({"nodePath": node_path, "nodeType": node.get_class(), "properties": props})
646
577
 
647
- var result = {
648
- "parentPath": parent_path,
649
- "parentType": parent.get_class(),
650
- "children": children
651
- }
578
+ # Free cached default instances; they were created via instantiate_class.
579
+ for klass in defaults_cache:
580
+ var inst = defaults_cache[klass]
581
+ if inst:
582
+ inst.free()
652
583
 
653
- print(JSON.stringify(result))
584
+ print(JSON.stringify({"results": results}))
654
585
 
655
586
  # Get full hierarchical tree structure of a scene
656
587
  func get_scene_tree(params):
@@ -659,12 +590,21 @@ func get_scene_tree(params):
659
590
  var scene_root = load_scene_instance(params.scene_path)
660
591
  if not scene_root:
661
592
  quit(1)
593
+ return
594
+
595
+ var tree_root = scene_root
596
+ if params.has("parent_path") and params.parent_path:
597
+ tree_root = find_node_by_path(scene_root, params.parent_path)
598
+ if not tree_root:
599
+ log_error("Parent node not found: " + str(params.parent_path))
600
+ quit(1)
601
+ return
662
602
 
663
603
  var max_depth = -1
664
604
  if params.has("max_depth"):
665
605
  max_depth = int(params.max_depth)
666
606
 
667
- var tree = build_tree_recursive(scene_root, "", 0, max_depth)
607
+ var tree = build_tree_recursive(tree_root, "", 0, max_depth)
668
608
  print(JSON.stringify(tree))
669
609
 
670
610
  func build_tree_recursive(node: Node, path: String, depth: int = 0, max_depth: int = -1) -> Dictionary:
@@ -676,10 +616,9 @@ func build_tree_recursive(node: Node, path: String, depth: int = 0, max_depth: i
676
616
  children.append(build_tree_recursive(child, node_path, depth + 1, max_depth))
677
617
 
678
618
  var script_path = ""
679
- if node.get_script():
680
- var script = node.get_script()
681
- if script.resource_path:
682
- script_path = script.resource_path
619
+ var script = node.get_script()
620
+ if script and script.resource_path:
621
+ script_path = script.resource_path
683
622
 
684
623
  return {
685
624
  "name": node.name,
@@ -696,22 +635,26 @@ func attach_script(params):
696
635
  var scene_root = load_scene_instance(params.scene_path)
697
636
  if not scene_root:
698
637
  quit(1)
638
+ return
699
639
 
700
640
  var node = find_node_by_path(scene_root, params.node_path)
701
641
  if not node:
702
642
  log_error("Node not found: " + params.node_path)
703
643
  quit(1)
644
+ return
704
645
 
705
646
  var full_script_path = normalize_scene_path(params.script_path)
706
647
 
707
648
  if not FileAccess.file_exists(full_script_path):
708
649
  log_error("Script file does not exist: " + full_script_path)
709
650
  quit(1)
651
+ return
710
652
 
711
653
  var script = load(full_script_path)
712
654
  if not script:
713
655
  log_error("Failed to load script: " + full_script_path)
714
656
  quit(1)
657
+ return
715
658
 
716
659
  node.set_script(script)
717
660
 
@@ -720,6 +663,7 @@ func attach_script(params):
720
663
  else:
721
664
  log_error("Failed to save scene after attaching script")
722
665
  quit(1)
666
+ return
723
667
 
724
668
  # ============================================
725
669
  # SIGNAL AND DUPLICATE OPERATIONS
@@ -728,15 +672,19 @@ func attach_script(params):
728
672
  # Duplicate a node and its children within a scene
729
673
  func duplicate_node(params):
730
674
  var scene_root = load_scene_instance(params.scene_path)
731
- if not scene_root: quit(1)
675
+ if not scene_root:
676
+ quit(1)
677
+ return
732
678
 
733
679
  var node = find_node_by_path(scene_root, params.node_path)
734
680
  if not node:
735
681
  log_error("Node not found: " + params.node_path)
736
682
  quit(1)
683
+ return
737
684
  if node == scene_root:
738
685
  log_error("Cannot duplicate the root node")
739
686
  quit(1)
687
+ return
740
688
 
741
689
  var duplicate = node.duplicate()
742
690
  if params.has("new_name"):
@@ -750,33 +698,36 @@ func duplicate_node(params):
750
698
  if not parent:
751
699
  log_error("Target parent not found: " + params.target_parent_path)
752
700
  quit(1)
701
+ return
753
702
 
754
703
  parent.add_child(duplicate)
755
704
  duplicate.owner = scene_root
756
- # Recursively set owner on all descendants
757
- for child in duplicate.get_children():
758
- set_owner_recursive(child, scene_root)
705
+ # Iterative BFS to set owner on all descendants — avoids recursion depth.
706
+ var queue: Array = duplicate.get_children()
707
+ while not queue.is_empty():
708
+ var current = queue.pop_front()
709
+ current.owner = scene_root
710
+ queue.append_array(current.get_children())
759
711
 
760
712
  if save_scene_to_path(scene_root, params.scene_path):
761
713
  print("Node duplicated successfully as '" + duplicate.name + "'")
762
714
  else:
763
715
  log_error("Failed to save scene after duplicating node")
764
716
  quit(1)
765
-
766
- func set_owner_recursive(node: Node, owner: Node):
767
- node.owner = owner
768
- for child in node.get_children():
769
- set_owner_recursive(child, owner)
717
+ return
770
718
 
771
719
  # List signals defined on a node and their current connections
772
720
  func get_node_signals(params):
773
721
  var scene_root = load_scene_instance(params.scene_path)
774
- if not scene_root: quit(1)
722
+ if not scene_root:
723
+ quit(1)
724
+ return
775
725
 
776
726
  var node = find_node_by_path(scene_root, params.node_path)
777
727
  if not node:
778
728
  log_error("Node not found: " + params.node_path)
779
729
  quit(1)
730
+ return
780
731
 
781
732
  var signals = []
782
733
  for sig in node.get_signal_list():
@@ -802,55 +753,70 @@ func get_node_signals(params):
802
753
  # Connect a signal from one node to a method on another node
803
754
  func connect_signal(params):
804
755
  var scene_root = load_scene_instance(params.scene_path)
805
- if not scene_root: quit(1)
756
+ if not scene_root:
757
+ quit(1)
758
+ return
806
759
 
807
760
  var source = find_node_by_path(scene_root, params.node_path)
808
761
  if not source:
809
762
  log_error("Source node not found: " + params.node_path)
810
763
  quit(1)
764
+ return
811
765
 
812
766
  var target = find_node_by_path(scene_root, params.target_node_path)
813
767
  if not target:
814
768
  log_error("Target node not found: " + params.target_node_path)
815
769
  quit(1)
770
+ return
816
771
 
817
772
  if not source.has_signal(params.signal):
818
773
  log_error("Signal does not exist: " + params.signal + " on " + source.get_class())
819
774
  quit(1)
775
+ return
820
776
 
821
777
  if not target.has_method(params.method):
822
778
  log_error("Method does not exist: " + params.method + " on " + target.get_class())
823
779
  quit(1)
780
+ return
824
781
 
825
- var err = source.connect(params.signal, Callable(target, params.method))
782
+ # CONNECT_PERSIST is required for the connection to be serialized into the
783
+ # packed scene; without it the connection is runtime-only and disappears on save.
784
+ var err = source.connect(params.signal, Callable(target, params.method), CONNECT_PERSIST)
826
785
  if err != OK:
827
786
  log_error("Failed to connect signal: " + str(err))
828
787
  quit(1)
788
+ return
829
789
 
830
790
  if save_scene_to_path(scene_root, params.scene_path):
831
791
  print("Signal '" + params.signal + "' connected from '" + params.node_path + "' to '" + params.target_node_path + "." + params.method + "'")
832
792
  else:
833
793
  log_error("Failed to save scene after connecting signal")
834
794
  quit(1)
795
+ return
835
796
 
836
797
  # Disconnect a signal connection between two nodes
837
798
  func disconnect_signal(params):
838
799
  var scene_root = load_scene_instance(params.scene_path)
839
- if not scene_root: quit(1)
800
+ if not scene_root:
801
+ quit(1)
802
+ return
840
803
 
841
804
  var source = find_node_by_path(scene_root, params.node_path)
842
805
  if not source:
843
806
  log_error("Source node not found: " + params.node_path)
844
807
  quit(1)
808
+ return
845
809
 
846
810
  var target = find_node_by_path(scene_root, params.target_node_path)
847
811
  if not target:
848
812
  log_error("Target node not found: " + params.target_node_path)
849
813
  quit(1)
814
+ return
850
815
 
851
816
  if not source.is_connected(params.signal, Callable(target, params.method)):
852
817
  log_error("Signal connection does not exist")
853
818
  quit(1)
819
+ return
854
820
 
855
821
  source.disconnect(params.signal, Callable(target, params.method))
856
822
 
@@ -859,6 +825,7 @@ func disconnect_signal(params):
859
825
  else:
860
826
  log_error("Failed to save scene after disconnecting signal")
861
827
  quit(1)
828
+ return
862
829
 
863
830
  # ============================================
864
831
  # VALIDATE OPERATION
@@ -869,6 +836,7 @@ func validate_resource(params):
869
836
  if not (params.has("script_path") or params.has("scene_path")):
870
837
  log_error("validate_resource requires script_path or scene_path")
871
838
  quit(1)
839
+ return
872
840
  var result = _validate_single(params)
873
841
  print(JSON.stringify({"valid": result.valid, "errors": result.errors}))
874
842
 
@@ -889,11 +857,20 @@ func _coerce_property_value(value):
889
857
  return Color(value.r, value.g, value.b, a)
890
858
  return value
891
859
 
892
- # Helper: collect node properties into a serializable Dictionary
893
- func _collect_node_properties(node: Node, changed_only: bool) -> Dictionary:
860
+ # Helper: collect node properties into a serializable Dictionary. When
861
+ # changed_only is true, compares each property against a default instance of
862
+ # the node's class. The defaults_cache dict is keyed by class name so the
863
+ # caller can reuse default instances across many nodes (caller is responsible
864
+ # for freeing the cache when done).
865
+ func _collect_node_properties(node: Node, changed_only: bool, defaults_cache: Dictionary) -> Dictionary:
894
866
  var default_node = null
895
867
  if changed_only:
896
- default_node = instantiate_class(node.get_class())
868
+ var klass = node.get_class()
869
+ if defaults_cache.has(klass):
870
+ default_node = defaults_cache[klass]
871
+ else:
872
+ default_node = instantiate_class(klass)
873
+ defaults_cache[klass] = default_node
897
874
 
898
875
  var properties = {}
899
876
  var property_list = node.get_property_list()
@@ -928,9 +905,6 @@ func _collect_node_properties(node: Node, changed_only: bool) -> Dictionary:
928
905
  else:
929
906
  properties[prop_name] = str(value)
930
907
 
931
- if default_node:
932
- default_node.free()
933
-
934
908
  return properties
935
909
 
936
910
  # Helper: validate a single target dict (script_path or scene_path)
@@ -958,46 +932,6 @@ func validate_batch(params: Dictionary) -> void:
958
932
  results.append(_validate_single(target))
959
933
  print(JSON.stringify({"results": results}))
960
934
 
961
- # Helper: add a node to a scene root without saving (returns error string or "")
962
- func _batch_add_node(scene_root: Node, op: Dictionary) -> String:
963
- var parent_path = "root"
964
- if op.has("parent_node_path"):
965
- parent_path = op.parent_node_path
966
- var parent = find_node_by_path(scene_root, parent_path)
967
- if not parent:
968
- return "Parent node not found: " + parent_path
969
- if not op.has("node_type") or op.node_type == "":
970
- return "node_type is required for add_node"
971
- if not op.has("node_name") or op.node_name == "":
972
- return "node_name is required for add_node"
973
- var new_node = instantiate_class(op.node_type)
974
- if not new_node:
975
- return "Failed to instantiate node of type: " + op.node_type
976
- new_node.name = op.node_name
977
- if op.has("properties"):
978
- for property in op.properties:
979
- new_node.set(property, op.properties[property])
980
- parent.add_child(new_node)
981
- new_node.owner = scene_root
982
- return ""
983
-
984
- # Helper: set a sprite texture without saving (returns error string or "")
985
- func _batch_load_sprite(scene_root: Node, op: Dictionary) -> String:
986
- if not op.has("node_path") or op.node_path == "":
987
- return "node_path is required for load_sprite"
988
- if not op.has("texture_path") or op.texture_path == "":
989
- return "texture_path is required for load_sprite"
990
- var sprite_node = find_node_by_path(scene_root, op.node_path)
991
- if not sprite_node:
992
- return "Node not found: " + op.node_path
993
- if not (sprite_node is Sprite2D or sprite_node is Sprite3D or sprite_node is TextureRect):
994
- return "Node is not sprite-compatible: " + sprite_node.get_class()
995
- var texture = load(normalize_scene_path(op.texture_path))
996
- if not texture:
997
- return "Failed to load texture: " + op.texture_path
998
- sprite_node.texture = texture
999
- return ""
1000
-
1001
935
  # Execute multiple scene operations in a single headless process
1002
936
  # Scenes are loaded once and cached in memory; mutations accumulate until a save op
1003
937
  func batch_scene_operations(params: Dictionary) -> void:
@@ -1028,18 +962,18 @@ func batch_scene_operations(params: Dictionary) -> void:
1028
962
  if scene_root == null:
1029
963
  result["error"] = "scene_path required for add_node"
1030
964
  else:
1031
- var err = _batch_add_node(scene_root, op)
1032
- if err != "":
1033
- result["error"] = err
965
+ var apply_result = _apply_add_node(scene_root, op)
966
+ if not apply_result.ok:
967
+ result["error"] = apply_result.error
1034
968
  else:
1035
969
  result["success"] = true
1036
970
  "load_sprite":
1037
971
  if scene_root == null:
1038
972
  result["error"] = "scene_path required for load_sprite"
1039
973
  else:
1040
- var err = _batch_load_sprite(scene_root, op)
1041
- if err != "":
1042
- result["error"] = err
974
+ var apply_result = _apply_load_sprite(scene_root, op)
975
+ if not apply_result.ok:
976
+ result["error"] = apply_result.error
1043
977
  else:
1044
978
  result["success"] = true
1045
979
  "save":
@@ -1067,51 +1001,3 @@ func batch_scene_operations(params: Dictionary) -> void:
1067
1001
  save_scene_to_path(scene_cache[scene_path], scene_path)
1068
1002
 
1069
1003
  print(JSON.stringify({"results": results}))
1070
-
1071
- # Update multiple node properties in a single headless process (loads and saves scene once)
1072
- func batch_set_node_properties(params: Dictionary) -> void:
1073
- var scene_root = load_scene_instance(params.scene_path)
1074
- if not scene_root:
1075
- print(JSON.stringify({"error": "Failed to load scene: " + params.scene_path, "results": []}))
1076
- return
1077
-
1078
- var abort_on_error = params.get("abort_on_error", false)
1079
- var results: Array = []
1080
-
1081
- for update in params.updates:
1082
- var result = {"nodePath": update.node_path, "property": update.property}
1083
- var node = find_node_by_path(scene_root, update.node_path)
1084
- if node == null:
1085
- result["error"] = "Node not found: " + update.node_path
1086
- else:
1087
- node.set(update.property, _coerce_property_value(update.value))
1088
- result["success"] = true
1089
- results.append(result)
1090
- if abort_on_error and result.has("error"):
1091
- break
1092
-
1093
- if save_scene_to_path(scene_root, params.scene_path):
1094
- print(JSON.stringify({"results": results}))
1095
- else:
1096
- print(JSON.stringify({"error": "Failed to save scene after batch updates", "partial_results": results}))
1097
-
1098
- # Get properties from multiple nodes in a single headless process (loads scene once)
1099
- func batch_get_node_properties(params: Dictionary) -> void:
1100
- var scene_root = load_scene_instance(params.scene_path)
1101
- if not scene_root:
1102
- print(JSON.stringify({"error": "Failed to load scene: " + params.scene_path, "results": []}))
1103
- return
1104
-
1105
- var results: Array = []
1106
-
1107
- for node_spec in params.nodes:
1108
- var node_path = node_spec.get("node_path", "")
1109
- var changed_only = node_spec.get("changed_only", false)
1110
- var node = find_node_by_path(scene_root, node_path)
1111
- if node == null:
1112
- results.append({"nodePath": node_path, "error": "Node not found"})
1113
- else:
1114
- var props = _collect_node_properties(node, changed_only)
1115
- results.append({"nodePath": node_path, "nodeType": node.get_class(), "properties": props})
1116
-
1117
- print(JSON.stringify({"results": results}))