godot-mcp-runtime 2.2.0 → 2.2.2

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.
@@ -1,1117 +1,1117 @@
1
- #!/usr/bin/env -S godot --headless --script
2
- extends SceneTree
3
-
4
- # Debug mode flag
5
- var debug_mode = false
6
-
7
- func _init():
8
- var args = OS.get_cmdline_args()
9
-
10
- # Check for debug flag
11
- debug_mode = "--debug-godot" in args
12
-
13
- # Find the script argument and determine the positions of operation and params
14
- var script_index = args.find("--script")
15
- if script_index == -1:
16
- log_error("Could not find --script argument")
17
- quit(1)
18
-
19
- var operation_index = script_index + 2
20
- var params_index = script_index + 3
21
-
22
- if args.size() <= params_index:
23
- log_error("Usage: godot --headless --script godot_operations.gd <operation> <json_params>")
24
- log_error("Not enough command-line arguments provided.")
25
- quit(1)
26
-
27
- log_debug("All arguments: " + str(args))
28
-
29
- var operation = args[operation_index]
30
- var params_json = args[params_index]
31
-
32
- log_info("Operation: " + operation)
33
- log_debug("Params JSON: " + params_json)
34
-
35
- var json = JSON.new()
36
- var error = json.parse(params_json)
37
- var params = null
38
-
39
- if error == OK:
40
- params = json.get_data()
41
- else:
42
- log_error("Failed to parse JSON parameters: " + params_json)
43
- log_error("JSON Error: " + json.get_error_message() + " at line " + str(json.get_error_line()))
44
- quit(1)
45
-
46
- if not params:
47
- log_error("Failed to parse JSON parameters: " + params_json)
48
- quit(1)
49
-
50
- log_info("Executing operation: " + operation)
51
-
52
- match operation:
53
- # Original operations
54
- "create_scene":
55
- create_scene(params)
56
- "add_node":
57
- add_node(params)
58
- "load_sprite":
59
- load_sprite(params)
60
- "export_mesh_library":
61
- export_mesh_library(params)
62
- "save_scene":
63
- 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
- "update_node_property":
72
- update_node_property(params)
73
- "get_node_properties":
74
- get_node_properties(params)
75
- "list_nodes":
76
- list_nodes(params)
77
- "get_scene_tree":
78
- get_scene_tree(params)
79
- "attach_script":
80
- attach_script(params)
81
- "duplicate_node":
82
- duplicate_node(params)
83
- "get_node_signals":
84
- get_node_signals(params)
85
- "connect_node_signal":
86
- connect_node_signal(params)
87
- "disconnect_node_signal":
88
- disconnect_node_signal(params)
89
- "validate_resource":
90
- validate_resource(params)
91
- # Batch operations
92
- "validate_batch":
93
- validate_batch(params)
94
- "batch_scene_operations":
95
- batch_scene_operations(params)
96
- "batch_update_node_properties":
97
- batch_update_node_properties(params)
98
- "batch_get_node_properties":
99
- batch_get_node_properties(params)
100
- _:
101
- log_error("Unknown operation: " + operation)
102
- quit(1)
103
-
104
- quit()
105
-
106
- # Logging functions
107
- func log_debug(message):
108
- if debug_mode:
109
- print("[DEBUG] " + message)
110
-
111
- func log_info(message):
112
- printerr("[INFO] " + message)
113
-
114
- func log_error(message):
115
- printerr("[ERROR] " + message)
116
-
117
- # Get a script by name or path
118
- func get_script_by_name(name_of_class):
119
- if debug_mode:
120
- printerr("Attempting to get script for class: " + name_of_class)
121
-
122
- if ResourceLoader.exists(name_of_class, "Script"):
123
- if debug_mode:
124
- printerr("Resource exists, loading directly: " + name_of_class)
125
- var script = load(name_of_class) as Script
126
- if script:
127
- if debug_mode:
128
- printerr("Successfully loaded script from path")
129
- return script
130
- else:
131
- printerr("Failed to load script from path: " + name_of_class)
132
- elif debug_mode:
133
- printerr("Resource not found, checking global class registry")
134
-
135
- var global_classes = ProjectSettings.get_global_class_list()
136
- if debug_mode:
137
- printerr("Searching through " + str(global_classes.size()) + " global classes")
138
-
139
- for global_class in global_classes:
140
- var found_name_of_class = global_class["class"]
141
- var found_path = global_class["path"]
142
-
143
- if found_name_of_class == name_of_class:
144
- if debug_mode:
145
- printerr("Found matching class in registry: " + found_name_of_class + " at path: " + found_path)
146
- var script = load(found_path) as Script
147
- if script:
148
- if debug_mode:
149
- printerr("Successfully loaded script from registry")
150
- return script
151
- else:
152
- printerr("Failed to load script from registry path: " + found_path)
153
- break
154
-
155
- printerr("Could not find script for class: " + name_of_class)
156
- return null
157
-
158
- # Instantiate a class by name
159
- func instantiate_class(name_of_class):
160
- if name_of_class.is_empty():
161
- printerr("Cannot instantiate class: name is empty")
162
- return null
163
-
164
- var result = null
165
- if debug_mode:
166
- printerr("Attempting to instantiate class: " + name_of_class)
167
-
168
- if ClassDB.class_exists(name_of_class):
169
- if debug_mode:
170
- printerr("Class exists in ClassDB, using ClassDB.instantiate()")
171
- if ClassDB.can_instantiate(name_of_class):
172
- result = ClassDB.instantiate(name_of_class)
173
- if result == null:
174
- printerr("ClassDB.instantiate() returned null for class: " + name_of_class)
175
- else:
176
- printerr("Class exists but cannot be instantiated: " + name_of_class)
177
- else:
178
- if debug_mode:
179
- printerr("Class not found in ClassDB, trying to get script")
180
- var script = get_script_by_name(name_of_class)
181
- if script is GDScript:
182
- if debug_mode:
183
- printerr("Found GDScript, creating instance")
184
- result = script.new()
185
- else:
186
- printerr("Failed to get script for class: " + name_of_class)
187
- return null
188
-
189
- if result == null:
190
- printerr("Failed to instantiate class: " + name_of_class)
191
- elif debug_mode:
192
- printerr("Successfully instantiated class: " + name_of_class + " of type: " + result.get_class())
193
-
194
- return result
195
-
196
- # Helper to normalize scene path
197
- func normalize_scene_path(scene_path: String) -> String:
198
- if not scene_path.begins_with("res://"):
199
- return "res://" + scene_path
200
- return scene_path
201
-
202
- # Helper to load and instantiate a scene
203
- func load_scene_instance(scene_path: String):
204
- var full_path = normalize_scene_path(scene_path)
205
- log_debug("Loading scene from: " + full_path)
206
-
207
- if not FileAccess.file_exists(full_path):
208
- log_error("Scene file does not exist: " + full_path)
209
- return null
210
-
211
- var scene = load(full_path)
212
- if not scene:
213
- log_error("Failed to load scene: " + full_path)
214
- return null
215
-
216
- var instance = scene.instantiate()
217
- if not instance:
218
- log_error("Failed to instantiate scene: " + full_path)
219
- return null
220
-
221
- return instance
222
-
223
- # Helper to find a node by path
224
- func find_node_by_path(scene_root: Node, node_path: String) -> Node:
225
- if node_path == "root" or node_path.is_empty():
226
- return scene_root
227
-
228
- var path = node_path
229
- if path.begins_with("root/"):
230
- path = path.substr(5)
231
-
232
- if path.is_empty():
233
- return scene_root
234
-
235
- return scene_root.get_node_or_null(path)
236
-
237
- # Helper to save a scene
238
- func save_scene_to_path(scene_root: Node, save_path: String) -> bool:
239
- var full_path = normalize_scene_path(save_path)
240
-
241
- var packed_scene = PackedScene.new()
242
- var result = packed_scene.pack(scene_root)
243
-
244
- if result != OK:
245
- log_error("Failed to pack scene: " + str(result))
246
- return false
247
-
248
- var save_error = ResourceSaver.save(packed_scene, full_path)
249
- if save_error != OK:
250
- log_error("Failed to save scene: " + str(save_error))
251
- return false
252
-
253
- return true
254
-
255
- # Create a new scene with a specified root node type
256
- func create_scene(params):
257
- printerr("Creating scene: " + params.scene_path)
258
-
259
- var full_scene_path = normalize_scene_path(params.scene_path)
260
- log_debug("Scene path: " + full_scene_path)
261
-
262
- var root_node_type = "Node2D"
263
- if params.has("root_node_type"):
264
- root_node_type = params.root_node_type
265
- log_debug("Root node type: " + root_node_type)
266
-
267
- var scene_root = instantiate_class(root_node_type)
268
- if not scene_root:
269
- log_error("Failed to instantiate node of type: " + root_node_type)
270
- quit(1)
271
-
272
- scene_root.name = "root"
273
- scene_root.owner = scene_root
274
-
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)
286
-
287
- if save_scene_to_path(scene_root, full_scene_path):
288
- print("Scene created successfully at: " + params.scene_path)
289
- else:
290
- log_error("Failed to create scene: " + params.scene_path)
291
- quit(1)
292
-
293
- # Add a node to an existing scene
294
- func add_node(params):
295
- printerr("Adding node to scene: " + params.scene_path)
296
-
297
- var scene_root = load_scene_instance(params.scene_path)
298
- if not scene_root:
299
- quit(1)
300
-
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)
313
- 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
325
-
326
- if save_scene_to_path(scene_root, params.scene_path):
327
- print("Node '" + params.node_name + "' of type '" + params.node_type + "' added successfully")
328
- else:
329
- log_error("Failed to save scene after adding node")
330
- quit(1)
331
-
332
- # Load a sprite into a Sprite2D node
333
- func load_sprite(params):
334
- printerr("Loading sprite into scene: " + params.scene_path)
335
-
336
- var scene_root = load_scene_instance(params.scene_path)
337
- if not scene_root:
338
- quit(1)
339
-
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)
353
- quit(1)
354
-
355
- sprite_node.texture = texture
356
-
357
- if save_scene_to_path(scene_root, params.scene_path):
358
- print("Sprite loaded successfully with texture: " + params.texture_path)
359
- else:
360
- log_error("Failed to save scene after loading sprite")
361
- quit(1)
362
-
363
- # Export a scene as a MeshLibrary resource
364
- func export_mesh_library(params):
365
- printerr("Exporting MeshLibrary from scene: " + params.scene_path)
366
-
367
- var scene_root = load_scene_instance(params.scene_path)
368
- if not scene_root:
369
- quit(1)
370
-
371
- var mesh_library = MeshLibrary.new()
372
-
373
- var mesh_item_names = params.mesh_item_names if params.has("mesh_item_names") else []
374
- var use_specific_items = mesh_item_names.size() > 0
375
-
376
- var item_id = 0
377
-
378
- for child in scene_root.get_children():
379
- if use_specific_items and not (child.name in mesh_item_names):
380
- continue
381
-
382
- var mesh_instance = null
383
- if child is MeshInstance3D:
384
- mesh_instance = child
385
- else:
386
- for descendant in child.get_children():
387
- if descendant is MeshInstance3D:
388
- mesh_instance = descendant
389
- break
390
-
391
- if mesh_instance and mesh_instance.mesh:
392
- mesh_library.create_item(item_id)
393
- mesh_library.set_item_name(item_id, child.name)
394
- mesh_library.set_item_mesh(item_id, mesh_instance.mesh)
395
-
396
- for collision_child in child.get_children():
397
- if collision_child is CollisionShape3D and collision_child.shape:
398
- mesh_library.set_item_shapes(item_id, [collision_child.shape])
399
- break
400
-
401
- if mesh_instance.mesh:
402
- mesh_library.set_item_preview(item_id, mesh_instance.mesh)
403
-
404
- item_id += 1
405
-
406
- if item_id > 0:
407
- var full_output_path = normalize_scene_path(params.output_path)
408
-
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)
417
-
418
- var error = ResourceSaver.save(mesh_library, full_output_path)
419
- if error == OK:
420
- print("MeshLibrary exported successfully with " + str(item_id) + " items to: " + params.output_path)
421
- else:
422
- log_error("Failed to save MeshLibrary: " + str(error))
423
- quit(1)
424
- else:
425
- log_error("No valid meshes found in the scene")
426
- quit(1)
427
-
428
- # Save changes to a scene file
429
- func save_scene(params):
430
- printerr("Saving scene: " + params.scene_path)
431
-
432
- var scene_root = load_scene_instance(params.scene_path)
433
- if not scene_root:
434
- quit(1)
435
-
436
- var save_path = params.new_path if params.has("new_path") else params.scene_path
437
-
438
- if save_scene_to_path(scene_root, save_path):
439
- print("Scene saved successfully to: " + save_path)
440
- else:
441
- log_error("Failed to save scene")
442
- 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))
540
-
541
- # ============================================
542
- # NEW NODE OPERATIONS
543
- # ============================================
544
-
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 update_node_property(params):
574
- printerr("Updating node property in scene: " + params.scene_path)
575
-
576
- var scene_root = load_scene_instance(params.scene_path)
577
- if not scene_root:
578
- quit(1)
579
-
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)
591
-
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)
597
-
598
- # Get all properties of a specific node
599
- func get_node_properties(params):
600
- printerr("Getting node properties from scene: " + params.scene_path)
601
-
602
- var scene_root = load_scene_instance(params.scene_path)
603
- 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)
610
-
611
- var changed_only = params.has("changed_only") and params.changed_only
612
- var properties = _collect_node_properties(node, changed_only)
613
-
614
- var result = {
615
- "nodePath": params.node_path,
616
- "nodeType": node.get_class(),
617
- "properties": properties
618
- }
619
-
620
- print(JSON.stringify(result))
621
-
622
- # List all child nodes under a parent
623
- func list_nodes(params):
624
- printerr("Listing nodes in scene: " + params.scene_path)
625
-
626
- var scene_root = load_scene_instance(params.scene_path)
627
- 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
633
-
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)
638
-
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
- })
646
-
647
- var result = {
648
- "parentPath": parent_path,
649
- "parentType": parent.get_class(),
650
- "children": children
651
- }
652
-
653
- print(JSON.stringify(result))
654
-
655
- # Get full hierarchical tree structure of a scene
656
- func get_scene_tree(params):
657
- printerr("Getting scene tree for: " + params.scene_path)
658
-
659
- var scene_root = load_scene_instance(params.scene_path)
660
- if not scene_root:
661
- quit(1)
662
-
663
- var max_depth = -1
664
- if params.has("max_depth"):
665
- max_depth = int(params.max_depth)
666
-
667
- var tree = build_tree_recursive(scene_root, "", 0, max_depth)
668
- print(JSON.stringify(tree))
669
-
670
- func build_tree_recursive(node: Node, path: String, depth: int = 0, max_depth: int = -1) -> Dictionary:
671
- var node_path = path + "/" + node.name if not path.is_empty() else node.name
672
-
673
- var children = []
674
- if max_depth < 0 or depth < max_depth:
675
- for child in node.get_children():
676
- children.append(build_tree_recursive(child, node_path, depth + 1, max_depth))
677
-
678
- 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
683
-
684
- return {
685
- "name": node.name,
686
- "type": node.get_class(),
687
- "path": node_path,
688
- "script": script_path,
689
- "children": children
690
- }
691
-
692
- # Attach or change a script on a node
693
- func attach_script(params):
694
- printerr("Attaching script to node in scene: " + params.scene_path)
695
-
696
- var scene_root = load_scene_instance(params.scene_path)
697
- if not scene_root:
698
- quit(1)
699
-
700
- var node = find_node_by_path(scene_root, params.node_path)
701
- if not node:
702
- log_error("Node not found: " + params.node_path)
703
- quit(1)
704
-
705
- var full_script_path = normalize_scene_path(params.script_path)
706
-
707
- if not FileAccess.file_exists(full_script_path):
708
- log_error("Script file does not exist: " + full_script_path)
709
- quit(1)
710
-
711
- var script = load(full_script_path)
712
- if not script:
713
- log_error("Failed to load script: " + full_script_path)
714
- quit(1)
715
-
716
- node.set_script(script)
717
-
718
- if save_scene_to_path(scene_root, params.scene_path):
719
- print("Script '" + params.script_path + "' attached successfully to node '" + params.node_path + "'")
720
- else:
721
- log_error("Failed to save scene after attaching script")
722
- quit(1)
723
-
724
- # ============================================
725
- # SIGNAL AND DUPLICATE OPERATIONS
726
- # ============================================
727
-
728
- # Duplicate a node and its children within a scene
729
- func duplicate_node(params):
730
- var scene_root = load_scene_instance(params.scene_path)
731
- if not scene_root: quit(1)
732
-
733
- var node = find_node_by_path(scene_root, params.node_path)
734
- if not node:
735
- log_error("Node not found: " + params.node_path)
736
- quit(1)
737
- if node == scene_root:
738
- log_error("Cannot duplicate the root node")
739
- quit(1)
740
-
741
- var duplicate = node.duplicate()
742
- if params.has("new_name"):
743
- duplicate.name = params.new_name
744
- else:
745
- duplicate.name = node.name + "2"
746
-
747
- var parent = node.get_parent()
748
- if params.has("target_parent_path"):
749
- parent = find_node_by_path(scene_root, params.target_parent_path)
750
- if not parent:
751
- log_error("Target parent not found: " + params.target_parent_path)
752
- quit(1)
753
-
754
- parent.add_child(duplicate)
755
- 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)
759
-
760
- if save_scene_to_path(scene_root, params.scene_path):
761
- print("Node duplicated successfully as '" + duplicate.name + "'")
762
- else:
763
- log_error("Failed to save scene after duplicating node")
764
- 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)
770
-
771
- # List signals defined on a node and their current connections
772
- func get_node_signals(params):
773
- var scene_root = load_scene_instance(params.scene_path)
774
- if not scene_root: quit(1)
775
-
776
- var node = find_node_by_path(scene_root, params.node_path)
777
- if not node:
778
- log_error("Node not found: " + params.node_path)
779
- quit(1)
780
-
781
- var signals = []
782
- for sig in node.get_signal_list():
783
- var sig_name = sig["name"]
784
- var connections = []
785
- for conn in node.get_signal_connection_list(sig_name):
786
- connections.append({
787
- "signal": sig_name,
788
- "target": str(conn["callable"].get_object().get_path()) if conn["callable"].get_object() else "unknown",
789
- "method": conn["callable"].get_method()
790
- })
791
- signals.append({
792
- "name": sig_name,
793
- "connections": connections
794
- })
795
-
796
- print(JSON.stringify({
797
- "nodePath": params.node_path,
798
- "nodeType": node.get_class(),
799
- "signals": signals
800
- }))
801
-
802
- # Connect a signal from one node to a method on another node
803
- func connect_node_signal(params):
804
- var scene_root = load_scene_instance(params.scene_path)
805
- if not scene_root: quit(1)
806
-
807
- var source = find_node_by_path(scene_root, params.node_path)
808
- if not source:
809
- log_error("Source node not found: " + params.node_path)
810
- quit(1)
811
-
812
- var target = find_node_by_path(scene_root, params.target_node_path)
813
- if not target:
814
- log_error("Target node not found: " + params.target_node_path)
815
- quit(1)
816
-
817
- if not source.has_signal(params.signal):
818
- log_error("Signal does not exist: " + params.signal + " on " + source.get_class())
819
- quit(1)
820
-
821
- if not target.has_method(params.method):
822
- log_error("Method does not exist: " + params.method + " on " + target.get_class())
823
- quit(1)
824
-
825
- var err = source.connect(params.signal, Callable(target, params.method))
826
- if err != OK:
827
- log_error("Failed to connect signal: " + str(err))
828
- quit(1)
829
-
830
- if save_scene_to_path(scene_root, params.scene_path):
831
- print("Signal '" + params.signal + "' connected from '" + params.node_path + "' to '" + params.target_node_path + "." + params.method + "'")
832
- else:
833
- log_error("Failed to save scene after connecting signal")
834
- quit(1)
835
-
836
- # Disconnect a signal connection between two nodes
837
- func disconnect_node_signal(params):
838
- var scene_root = load_scene_instance(params.scene_path)
839
- if not scene_root: quit(1)
840
-
841
- var source = find_node_by_path(scene_root, params.node_path)
842
- if not source:
843
- log_error("Source node not found: " + params.node_path)
844
- quit(1)
845
-
846
- var target = find_node_by_path(scene_root, params.target_node_path)
847
- if not target:
848
- log_error("Target node not found: " + params.target_node_path)
849
- quit(1)
850
-
851
- if not source.is_connected(params.signal, Callable(target, params.method)):
852
- log_error("Signal connection does not exist")
853
- quit(1)
854
-
855
- source.disconnect(params.signal, Callable(target, params.method))
856
-
857
- if save_scene_to_path(scene_root, params.scene_path):
858
- print("Signal '" + params.signal + "' disconnected from '" + params.target_node_path + "." + params.method + "'")
859
- else:
860
- log_error("Failed to save scene after disconnecting signal")
861
- quit(1)
862
-
863
- # ============================================
864
- # VALIDATE OPERATION
865
- # ============================================
866
-
867
- # Validate a GDScript or scene file by loading it headlessly
868
- func validate_resource(params):
869
- if not (params.has("script_path") or params.has("scene_path")):
870
- log_error("validate_resource requires script_path or scene_path")
871
- quit(1)
872
- var result = _validate_single(params)
873
- print(JSON.stringify({"valid": result.valid, "errors": result.errors}))
874
-
875
- # ============================================
876
- # BATCH OPERATIONS
877
- # ============================================
878
-
879
- # Helper: coerce a JSON-parsed value to a GDScript type (Vector2, Vector3, Color)
880
- func _coerce_property_value(value):
881
- if typeof(value) == TYPE_DICTIONARY:
882
- if value.has("x") and value.has("y"):
883
- if value.has("z"):
884
- return Vector3(value.x, value.y, value.z)
885
- else:
886
- return Vector2(value.x, value.y)
887
- elif value.has("r") and value.has("g") and value.has("b"):
888
- var a = value.a if value.has("a") else 1.0
889
- return Color(value.r, value.g, value.b, a)
890
- return value
891
-
892
- # Helper: collect node properties into a serializable Dictionary
893
- func _collect_node_properties(node: Node, changed_only: bool) -> Dictionary:
894
- var default_node = null
895
- if changed_only:
896
- default_node = instantiate_class(node.get_class())
897
-
898
- var properties = {}
899
- var property_list = node.get_property_list()
900
-
901
- for prop in property_list:
902
- var prop_name = prop["name"]
903
- var prop_usage = prop["usage"]
904
-
905
- if prop_usage & PROPERTY_USAGE_STORAGE or prop_usage & PROPERTY_USAGE_EDITOR:
906
- var value = node.get(prop_name)
907
-
908
- if default_node and default_node.get(prop_name) == value:
909
- continue
910
-
911
- if value is Vector2:
912
- properties[prop_name] = {"x": value.x, "y": value.y}
913
- elif value is Vector3:
914
- properties[prop_name] = {"x": value.x, "y": value.y, "z": value.z}
915
- elif value is Color:
916
- properties[prop_name] = {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
917
- elif value is Transform2D:
918
- properties[prop_name] = str(value)
919
- elif value is Transform3D:
920
- properties[prop_name] = str(value)
921
- elif value is Object:
922
- if value:
923
- properties[prop_name] = value.get_class()
924
- else:
925
- properties[prop_name] = null
926
- elif typeof(value) in [TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_ARRAY, TYPE_DICTIONARY]:
927
- properties[prop_name] = value
928
- else:
929
- properties[prop_name] = str(value)
930
-
931
- if default_node:
932
- default_node.free()
933
-
934
- return properties
935
-
936
- # Helper: validate a single target dict (script_path or scene_path)
937
- func _validate_single(target: Dictionary) -> Dictionary:
938
- if target.has("script_path") and target.script_path != "":
939
- var path = normalize_scene_path(target.script_path)
940
- if not FileAccess.file_exists(path):
941
- return {"valid": false, "errors": [{"message": "File not found: " + path}], "target": target.script_path}
942
- var resource = load(path)
943
- # Actual parse errors go to stderr and are parsed by TypeScript
944
- return {"valid": resource != null, "errors": [], "target": target.script_path}
945
- elif target.has("scene_path") and target.scene_path != "":
946
- var path = normalize_scene_path(target.scene_path)
947
- if not FileAccess.file_exists(path):
948
- return {"valid": false, "errors": [{"message": "File not found: " + path}], "target": target.scene_path}
949
- var scene = load(path)
950
- return {"valid": scene != null, "errors": [], "target": target.scene_path}
951
- else:
952
- return {"valid": false, "errors": [{"message": "No valid target: provide script_path or scene_path"}], "target": ""}
953
-
954
- # Validate multiple scripts/scenes in a single headless process
955
- func validate_batch(params: Dictionary) -> void:
956
- var results: Array = []
957
- for target in params.targets:
958
- results.append(_validate_single(target))
959
- print(JSON.stringify({"results": results}))
960
-
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
- # Execute multiple scene operations in a single headless process
1002
- # Scenes are loaded once and cached in memory; mutations accumulate until a save op
1003
- func batch_scene_operations(params: Dictionary) -> void:
1004
- var abort_on_error = params.get("abort_on_error", false)
1005
- var results: Array = []
1006
- var scene_cache: Dictionary = {}
1007
-
1008
- for op in params.operations:
1009
- var op_name = op.get("operation", "")
1010
- var scene_path = op.get("scene_path", "")
1011
- var result = {"operation": op_name, "scenePath": scene_path}
1012
-
1013
- if scene_path != "" and scene_path not in scene_cache:
1014
- var scene_root = load_scene_instance(scene_path)
1015
- if scene_root:
1016
- scene_cache[scene_path] = scene_root
1017
- else:
1018
- result["error"] = "Failed to load scene: " + scene_path
1019
- results.append(result)
1020
- if abort_on_error:
1021
- break
1022
- continue
1023
-
1024
- var scene_root = scene_cache.get(scene_path, null) if scene_path != "" else null
1025
-
1026
- match op_name:
1027
- "add_node":
1028
- if scene_root == null:
1029
- result["error"] = "scene_path required for add_node"
1030
- else:
1031
- var err = _batch_add_node(scene_root, op)
1032
- if err != "":
1033
- result["error"] = err
1034
- else:
1035
- result["success"] = true
1036
- "load_sprite":
1037
- if scene_root == null:
1038
- result["error"] = "scene_path required for load_sprite"
1039
- else:
1040
- var err = _batch_load_sprite(scene_root, op)
1041
- if err != "":
1042
- result["error"] = err
1043
- else:
1044
- result["success"] = true
1045
- "save":
1046
- if scene_root == null:
1047
- result["error"] = "scene_path required for save"
1048
- else:
1049
- var new_path = op.get("new_path", scene_path)
1050
- if save_scene_to_path(scene_root, new_path):
1051
- result["success"] = true
1052
- # Only evict on normal save; save-as leaves the mutated scene in
1053
- # cache so subsequent ops on scene_path still see accumulated mutations.
1054
- if new_path == scene_path:
1055
- scene_cache.erase(scene_path)
1056
- else:
1057
- result["error"] = "Failed to save scene: " + scene_path
1058
- _:
1059
- result["error"] = "Unknown batch operation: " + op_name
1060
-
1061
- results.append(result)
1062
- if abort_on_error and result.has("error"):
1063
- break
1064
-
1065
- # Auto-save any scenes that were mutated but not explicitly saved
1066
- for scene_path in scene_cache:
1067
- save_scene_to_path(scene_cache[scene_path], scene_path)
1068
-
1069
- print(JSON.stringify({"results": results}))
1070
-
1071
- # Update multiple node properties in a single headless process (loads and saves scene once)
1072
- func batch_update_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}))
1
+ #!/usr/bin/env -S godot --headless --script
2
+ extends SceneTree
3
+
4
+ # Debug mode flag
5
+ var debug_mode = false
6
+
7
+ func _init():
8
+ var args = OS.get_cmdline_args()
9
+
10
+ # Check for debug flag
11
+ debug_mode = "--debug-godot" in args
12
+
13
+ # Find the script argument and determine the positions of operation and params
14
+ var script_index = args.find("--script")
15
+ if script_index == -1:
16
+ log_error("Could not find --script argument")
17
+ quit(1)
18
+
19
+ var operation_index = script_index + 2
20
+ var params_index = script_index + 3
21
+
22
+ if args.size() <= params_index:
23
+ log_error("Usage: godot --headless --script godot_operations.gd <operation> <json_params>")
24
+ log_error("Not enough command-line arguments provided.")
25
+ quit(1)
26
+
27
+ log_debug("All arguments: " + str(args))
28
+
29
+ var operation = args[operation_index]
30
+ var params_json = args[params_index]
31
+
32
+ log_info("Operation: " + operation)
33
+ log_debug("Params JSON: " + params_json)
34
+
35
+ var json = JSON.new()
36
+ var error = json.parse(params_json)
37
+ var params = null
38
+
39
+ if error == OK:
40
+ params = json.get_data()
41
+ else:
42
+ log_error("Failed to parse JSON parameters: " + params_json)
43
+ log_error("JSON Error: " + json.get_error_message() + " at line " + str(json.get_error_line()))
44
+ quit(1)
45
+
46
+ if not params:
47
+ log_error("Failed to parse JSON parameters: " + params_json)
48
+ quit(1)
49
+
50
+ log_info("Executing operation: " + operation)
51
+
52
+ match operation:
53
+ # Original operations
54
+ "create_scene":
55
+ create_scene(params)
56
+ "add_node":
57
+ add_node(params)
58
+ "load_sprite":
59
+ load_sprite(params)
60
+ "export_mesh_library":
61
+ export_mesh_library(params)
62
+ "save_scene":
63
+ 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)
73
+ "get_node_properties":
74
+ get_node_properties(params)
75
+ "list_nodes":
76
+ list_nodes(params)
77
+ "get_scene_tree":
78
+ get_scene_tree(params)
79
+ "attach_script":
80
+ attach_script(params)
81
+ "duplicate_node":
82
+ duplicate_node(params)
83
+ "get_node_signals":
84
+ get_node_signals(params)
85
+ "connect_signal":
86
+ connect_signal(params)
87
+ "disconnect_signal":
88
+ disconnect_signal(params)
89
+ "validate_resource":
90
+ validate_resource(params)
91
+ # Batch operations
92
+ "validate_batch":
93
+ validate_batch(params)
94
+ "batch_scene_operations":
95
+ 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
+ _:
101
+ log_error("Unknown operation: " + operation)
102
+ quit(1)
103
+
104
+ quit()
105
+
106
+ # Logging functions
107
+ func log_debug(message):
108
+ if debug_mode:
109
+ print("[DEBUG] " + message)
110
+
111
+ func log_info(message):
112
+ printerr("[INFO] " + message)
113
+
114
+ func log_error(message):
115
+ printerr("[ERROR] " + message)
116
+
117
+ # Get a script by name or path
118
+ func get_script_by_name(name_of_class):
119
+ if debug_mode:
120
+ printerr("Attempting to get script for class: " + name_of_class)
121
+
122
+ if ResourceLoader.exists(name_of_class, "Script"):
123
+ if debug_mode:
124
+ printerr("Resource exists, loading directly: " + name_of_class)
125
+ var script = load(name_of_class) as Script
126
+ if script:
127
+ if debug_mode:
128
+ printerr("Successfully loaded script from path")
129
+ return script
130
+ else:
131
+ printerr("Failed to load script from path: " + name_of_class)
132
+ elif debug_mode:
133
+ printerr("Resource not found, checking global class registry")
134
+
135
+ var global_classes = ProjectSettings.get_global_class_list()
136
+ if debug_mode:
137
+ printerr("Searching through " + str(global_classes.size()) + " global classes")
138
+
139
+ for global_class in global_classes:
140
+ var found_name_of_class = global_class["class"]
141
+ var found_path = global_class["path"]
142
+
143
+ if found_name_of_class == name_of_class:
144
+ if debug_mode:
145
+ printerr("Found matching class in registry: " + found_name_of_class + " at path: " + found_path)
146
+ var script = load(found_path) as Script
147
+ if script:
148
+ if debug_mode:
149
+ printerr("Successfully loaded script from registry")
150
+ return script
151
+ else:
152
+ printerr("Failed to load script from registry path: " + found_path)
153
+ break
154
+
155
+ printerr("Could not find script for class: " + name_of_class)
156
+ return null
157
+
158
+ # Instantiate a class by name
159
+ func instantiate_class(name_of_class):
160
+ if name_of_class.is_empty():
161
+ printerr("Cannot instantiate class: name is empty")
162
+ return null
163
+
164
+ var result = null
165
+ if debug_mode:
166
+ printerr("Attempting to instantiate class: " + name_of_class)
167
+
168
+ if ClassDB.class_exists(name_of_class):
169
+ if debug_mode:
170
+ printerr("Class exists in ClassDB, using ClassDB.instantiate()")
171
+ if ClassDB.can_instantiate(name_of_class):
172
+ result = ClassDB.instantiate(name_of_class)
173
+ if result == null:
174
+ printerr("ClassDB.instantiate() returned null for class: " + name_of_class)
175
+ else:
176
+ printerr("Class exists but cannot be instantiated: " + name_of_class)
177
+ else:
178
+ if debug_mode:
179
+ printerr("Class not found in ClassDB, trying to get script")
180
+ var script = get_script_by_name(name_of_class)
181
+ if script is GDScript:
182
+ if debug_mode:
183
+ printerr("Found GDScript, creating instance")
184
+ result = script.new()
185
+ else:
186
+ printerr("Failed to get script for class: " + name_of_class)
187
+ return null
188
+
189
+ if result == null:
190
+ printerr("Failed to instantiate class: " + name_of_class)
191
+ elif debug_mode:
192
+ printerr("Successfully instantiated class: " + name_of_class + " of type: " + result.get_class())
193
+
194
+ return result
195
+
196
+ # Helper to normalize scene path
197
+ func normalize_scene_path(scene_path: String) -> String:
198
+ if not scene_path.begins_with("res://"):
199
+ return "res://" + scene_path
200
+ return scene_path
201
+
202
+ # Helper to load and instantiate a scene
203
+ func load_scene_instance(scene_path: String):
204
+ var full_path = normalize_scene_path(scene_path)
205
+ log_debug("Loading scene from: " + full_path)
206
+
207
+ if not FileAccess.file_exists(full_path):
208
+ log_error("Scene file does not exist: " + full_path)
209
+ return null
210
+
211
+ var scene = load(full_path)
212
+ if not scene:
213
+ log_error("Failed to load scene: " + full_path)
214
+ return null
215
+
216
+ var instance = scene.instantiate()
217
+ if not instance:
218
+ log_error("Failed to instantiate scene: " + full_path)
219
+ return null
220
+
221
+ return instance
222
+
223
+ # Helper to find a node by path
224
+ func find_node_by_path(scene_root: Node, node_path: String) -> Node:
225
+ if node_path == "root" or node_path.is_empty():
226
+ return scene_root
227
+
228
+ var path = node_path
229
+ if path.begins_with("root/"):
230
+ path = path.substr(5)
231
+
232
+ if path.is_empty():
233
+ return scene_root
234
+
235
+ return scene_root.get_node_or_null(path)
236
+
237
+ # Helper to save a scene
238
+ func save_scene_to_path(scene_root: Node, save_path: String) -> bool:
239
+ var full_path = normalize_scene_path(save_path)
240
+
241
+ var packed_scene = PackedScene.new()
242
+ var result = packed_scene.pack(scene_root)
243
+
244
+ if result != OK:
245
+ log_error("Failed to pack scene: " + str(result))
246
+ return false
247
+
248
+ var save_error = ResourceSaver.save(packed_scene, full_path)
249
+ if save_error != OK:
250
+ log_error("Failed to save scene: " + str(save_error))
251
+ return false
252
+
253
+ return true
254
+
255
+ # Create a new scene with a specified root node type
256
+ func create_scene(params):
257
+ printerr("Creating scene: " + params.scene_path)
258
+
259
+ var full_scene_path = normalize_scene_path(params.scene_path)
260
+ log_debug("Scene path: " + full_scene_path)
261
+
262
+ var root_node_type = "Node2D"
263
+ if params.has("root_node_type"):
264
+ root_node_type = params.root_node_type
265
+ log_debug("Root node type: " + root_node_type)
266
+
267
+ var scene_root = instantiate_class(root_node_type)
268
+ if not scene_root:
269
+ log_error("Failed to instantiate node of type: " + root_node_type)
270
+ quit(1)
271
+
272
+ scene_root.name = "root"
273
+ scene_root.owner = scene_root
274
+
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)
286
+
287
+ if save_scene_to_path(scene_root, full_scene_path):
288
+ print("Scene created successfully at: " + params.scene_path)
289
+ else:
290
+ log_error("Failed to create scene: " + params.scene_path)
291
+ quit(1)
292
+
293
+ # Add a node to an existing scene
294
+ func add_node(params):
295
+ printerr("Adding node to scene: " + params.scene_path)
296
+
297
+ var scene_root = load_scene_instance(params.scene_path)
298
+ if not scene_root:
299
+ quit(1)
300
+
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)
313
+ 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
325
+
326
+ if save_scene_to_path(scene_root, params.scene_path):
327
+ print("Node '" + params.node_name + "' of type '" + params.node_type + "' added successfully")
328
+ else:
329
+ log_error("Failed to save scene after adding node")
330
+ quit(1)
331
+
332
+ # Load a sprite into a Sprite2D node
333
+ func load_sprite(params):
334
+ printerr("Loading sprite into scene: " + params.scene_path)
335
+
336
+ var scene_root = load_scene_instance(params.scene_path)
337
+ if not scene_root:
338
+ quit(1)
339
+
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)
353
+ quit(1)
354
+
355
+ sprite_node.texture = texture
356
+
357
+ if save_scene_to_path(scene_root, params.scene_path):
358
+ print("Sprite loaded successfully with texture: " + params.texture_path)
359
+ else:
360
+ log_error("Failed to save scene after loading sprite")
361
+ quit(1)
362
+
363
+ # Export a scene as a MeshLibrary resource
364
+ func export_mesh_library(params):
365
+ printerr("Exporting MeshLibrary from scene: " + params.scene_path)
366
+
367
+ var scene_root = load_scene_instance(params.scene_path)
368
+ if not scene_root:
369
+ quit(1)
370
+
371
+ var mesh_library = MeshLibrary.new()
372
+
373
+ var mesh_item_names = params.mesh_item_names if params.has("mesh_item_names") else []
374
+ var use_specific_items = mesh_item_names.size() > 0
375
+
376
+ var item_id = 0
377
+
378
+ for child in scene_root.get_children():
379
+ if use_specific_items and not (child.name in mesh_item_names):
380
+ continue
381
+
382
+ var mesh_instance = null
383
+ if child is MeshInstance3D:
384
+ mesh_instance = child
385
+ else:
386
+ for descendant in child.get_children():
387
+ if descendant is MeshInstance3D:
388
+ mesh_instance = descendant
389
+ break
390
+
391
+ if mesh_instance and mesh_instance.mesh:
392
+ mesh_library.create_item(item_id)
393
+ mesh_library.set_item_name(item_id, child.name)
394
+ mesh_library.set_item_mesh(item_id, mesh_instance.mesh)
395
+
396
+ for collision_child in child.get_children():
397
+ if collision_child is CollisionShape3D and collision_child.shape:
398
+ mesh_library.set_item_shapes(item_id, [collision_child.shape])
399
+ break
400
+
401
+ if mesh_instance.mesh:
402
+ mesh_library.set_item_preview(item_id, mesh_instance.mesh)
403
+
404
+ item_id += 1
405
+
406
+ if item_id > 0:
407
+ var full_output_path = normalize_scene_path(params.output_path)
408
+
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)
417
+
418
+ var error = ResourceSaver.save(mesh_library, full_output_path)
419
+ if error == OK:
420
+ print("MeshLibrary exported successfully with " + str(item_id) + " items to: " + params.output_path)
421
+ else:
422
+ log_error("Failed to save MeshLibrary: " + str(error))
423
+ quit(1)
424
+ else:
425
+ log_error("No valid meshes found in the scene")
426
+ quit(1)
427
+
428
+ # Save changes to a scene file
429
+ func save_scene(params):
430
+ printerr("Saving scene: " + params.scene_path)
431
+
432
+ var scene_root = load_scene_instance(params.scene_path)
433
+ if not scene_root:
434
+ quit(1)
435
+
436
+ var save_path = params.new_path if params.has("new_path") else params.scene_path
437
+
438
+ if save_scene_to_path(scene_root, save_path):
439
+ print("Scene saved successfully to: " + save_path)
440
+ else:
441
+ log_error("Failed to save scene")
442
+ 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))
540
+
541
+ # ============================================
542
+ # NEW NODE OPERATIONS
543
+ # ============================================
544
+
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)
575
+
576
+ var scene_root = load_scene_instance(params.scene_path)
577
+ if not scene_root:
578
+ quit(1)
579
+
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)
591
+
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)
597
+
598
+ # Get all properties of a specific node
599
+ func get_node_properties(params):
600
+ printerr("Getting node properties from scene: " + params.scene_path)
601
+
602
+ var scene_root = load_scene_instance(params.scene_path)
603
+ 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)
610
+
611
+ var changed_only = params.has("changed_only") and params.changed_only
612
+ var properties = _collect_node_properties(node, changed_only)
613
+
614
+ var result = {
615
+ "nodePath": params.node_path,
616
+ "nodeType": node.get_class(),
617
+ "properties": properties
618
+ }
619
+
620
+ print(JSON.stringify(result))
621
+
622
+ # List all child nodes under a parent
623
+ func list_nodes(params):
624
+ printerr("Listing nodes in scene: " + params.scene_path)
625
+
626
+ var scene_root = load_scene_instance(params.scene_path)
627
+ 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
633
+
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)
638
+
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
+ })
646
+
647
+ var result = {
648
+ "parentPath": parent_path,
649
+ "parentType": parent.get_class(),
650
+ "children": children
651
+ }
652
+
653
+ print(JSON.stringify(result))
654
+
655
+ # Get full hierarchical tree structure of a scene
656
+ func get_scene_tree(params):
657
+ printerr("Getting scene tree for: " + params.scene_path)
658
+
659
+ var scene_root = load_scene_instance(params.scene_path)
660
+ if not scene_root:
661
+ quit(1)
662
+
663
+ var max_depth = -1
664
+ if params.has("max_depth"):
665
+ max_depth = int(params.max_depth)
666
+
667
+ var tree = build_tree_recursive(scene_root, "", 0, max_depth)
668
+ print(JSON.stringify(tree))
669
+
670
+ func build_tree_recursive(node: Node, path: String, depth: int = 0, max_depth: int = -1) -> Dictionary:
671
+ var node_path = path + "/" + node.name if not path.is_empty() else node.name
672
+
673
+ var children = []
674
+ if max_depth < 0 or depth < max_depth:
675
+ for child in node.get_children():
676
+ children.append(build_tree_recursive(child, node_path, depth + 1, max_depth))
677
+
678
+ 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
683
+
684
+ return {
685
+ "name": node.name,
686
+ "type": node.get_class(),
687
+ "path": node_path,
688
+ "script": script_path,
689
+ "children": children
690
+ }
691
+
692
+ # Attach or change a script on a node
693
+ func attach_script(params):
694
+ printerr("Attaching script to node in scene: " + params.scene_path)
695
+
696
+ var scene_root = load_scene_instance(params.scene_path)
697
+ if not scene_root:
698
+ quit(1)
699
+
700
+ var node = find_node_by_path(scene_root, params.node_path)
701
+ if not node:
702
+ log_error("Node not found: " + params.node_path)
703
+ quit(1)
704
+
705
+ var full_script_path = normalize_scene_path(params.script_path)
706
+
707
+ if not FileAccess.file_exists(full_script_path):
708
+ log_error("Script file does not exist: " + full_script_path)
709
+ quit(1)
710
+
711
+ var script = load(full_script_path)
712
+ if not script:
713
+ log_error("Failed to load script: " + full_script_path)
714
+ quit(1)
715
+
716
+ node.set_script(script)
717
+
718
+ if save_scene_to_path(scene_root, params.scene_path):
719
+ print("Script '" + params.script_path + "' attached successfully to node '" + params.node_path + "'")
720
+ else:
721
+ log_error("Failed to save scene after attaching script")
722
+ quit(1)
723
+
724
+ # ============================================
725
+ # SIGNAL AND DUPLICATE OPERATIONS
726
+ # ============================================
727
+
728
+ # Duplicate a node and its children within a scene
729
+ func duplicate_node(params):
730
+ var scene_root = load_scene_instance(params.scene_path)
731
+ if not scene_root: quit(1)
732
+
733
+ var node = find_node_by_path(scene_root, params.node_path)
734
+ if not node:
735
+ log_error("Node not found: " + params.node_path)
736
+ quit(1)
737
+ if node == scene_root:
738
+ log_error("Cannot duplicate the root node")
739
+ quit(1)
740
+
741
+ var duplicate = node.duplicate()
742
+ if params.has("new_name"):
743
+ duplicate.name = params.new_name
744
+ else:
745
+ duplicate.name = node.name + "2"
746
+
747
+ var parent = node.get_parent()
748
+ if params.has("target_parent_path"):
749
+ parent = find_node_by_path(scene_root, params.target_parent_path)
750
+ if not parent:
751
+ log_error("Target parent not found: " + params.target_parent_path)
752
+ quit(1)
753
+
754
+ parent.add_child(duplicate)
755
+ 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)
759
+
760
+ if save_scene_to_path(scene_root, params.scene_path):
761
+ print("Node duplicated successfully as '" + duplicate.name + "'")
762
+ else:
763
+ log_error("Failed to save scene after duplicating node")
764
+ 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)
770
+
771
+ # List signals defined on a node and their current connections
772
+ func get_node_signals(params):
773
+ var scene_root = load_scene_instance(params.scene_path)
774
+ if not scene_root: quit(1)
775
+
776
+ var node = find_node_by_path(scene_root, params.node_path)
777
+ if not node:
778
+ log_error("Node not found: " + params.node_path)
779
+ quit(1)
780
+
781
+ var signals = []
782
+ for sig in node.get_signal_list():
783
+ var sig_name = sig["name"]
784
+ var connections = []
785
+ for conn in node.get_signal_connection_list(sig_name):
786
+ connections.append({
787
+ "signal": sig_name,
788
+ "target": str(conn["callable"].get_object().get_path()) if conn["callable"].get_object() else "unknown",
789
+ "method": conn["callable"].get_method()
790
+ })
791
+ signals.append({
792
+ "name": sig_name,
793
+ "connections": connections
794
+ })
795
+
796
+ print(JSON.stringify({
797
+ "nodePath": params.node_path,
798
+ "nodeType": node.get_class(),
799
+ "signals": signals
800
+ }))
801
+
802
+ # Connect a signal from one node to a method on another node
803
+ func connect_signal(params):
804
+ var scene_root = load_scene_instance(params.scene_path)
805
+ if not scene_root: quit(1)
806
+
807
+ var source = find_node_by_path(scene_root, params.node_path)
808
+ if not source:
809
+ log_error("Source node not found: " + params.node_path)
810
+ quit(1)
811
+
812
+ var target = find_node_by_path(scene_root, params.target_node_path)
813
+ if not target:
814
+ log_error("Target node not found: " + params.target_node_path)
815
+ quit(1)
816
+
817
+ if not source.has_signal(params.signal):
818
+ log_error("Signal does not exist: " + params.signal + " on " + source.get_class())
819
+ quit(1)
820
+
821
+ if not target.has_method(params.method):
822
+ log_error("Method does not exist: " + params.method + " on " + target.get_class())
823
+ quit(1)
824
+
825
+ var err = source.connect(params.signal, Callable(target, params.method))
826
+ if err != OK:
827
+ log_error("Failed to connect signal: " + str(err))
828
+ quit(1)
829
+
830
+ if save_scene_to_path(scene_root, params.scene_path):
831
+ print("Signal '" + params.signal + "' connected from '" + params.node_path + "' to '" + params.target_node_path + "." + params.method + "'")
832
+ else:
833
+ log_error("Failed to save scene after connecting signal")
834
+ quit(1)
835
+
836
+ # Disconnect a signal connection between two nodes
837
+ func disconnect_signal(params):
838
+ var scene_root = load_scene_instance(params.scene_path)
839
+ if not scene_root: quit(1)
840
+
841
+ var source = find_node_by_path(scene_root, params.node_path)
842
+ if not source:
843
+ log_error("Source node not found: " + params.node_path)
844
+ quit(1)
845
+
846
+ var target = find_node_by_path(scene_root, params.target_node_path)
847
+ if not target:
848
+ log_error("Target node not found: " + params.target_node_path)
849
+ quit(1)
850
+
851
+ if not source.is_connected(params.signal, Callable(target, params.method)):
852
+ log_error("Signal connection does not exist")
853
+ quit(1)
854
+
855
+ source.disconnect(params.signal, Callable(target, params.method))
856
+
857
+ if save_scene_to_path(scene_root, params.scene_path):
858
+ print("Signal '" + params.signal + "' disconnected from '" + params.target_node_path + "." + params.method + "'")
859
+ else:
860
+ log_error("Failed to save scene after disconnecting signal")
861
+ quit(1)
862
+
863
+ # ============================================
864
+ # VALIDATE OPERATION
865
+ # ============================================
866
+
867
+ # Validate a GDScript or scene file by loading it headlessly
868
+ func validate_resource(params):
869
+ if not (params.has("script_path") or params.has("scene_path")):
870
+ log_error("validate_resource requires script_path or scene_path")
871
+ quit(1)
872
+ var result = _validate_single(params)
873
+ print(JSON.stringify({"valid": result.valid, "errors": result.errors}))
874
+
875
+ # ============================================
876
+ # BATCH OPERATIONS
877
+ # ============================================
878
+
879
+ # Helper: coerce a JSON-parsed value to a GDScript type (Vector2, Vector3, Color)
880
+ func _coerce_property_value(value):
881
+ if typeof(value) == TYPE_DICTIONARY:
882
+ if value.has("x") and value.has("y"):
883
+ if value.has("z"):
884
+ return Vector3(value.x, value.y, value.z)
885
+ else:
886
+ return Vector2(value.x, value.y)
887
+ elif value.has("r") and value.has("g") and value.has("b"):
888
+ var a = value.a if value.has("a") else 1.0
889
+ return Color(value.r, value.g, value.b, a)
890
+ return value
891
+
892
+ # Helper: collect node properties into a serializable Dictionary
893
+ func _collect_node_properties(node: Node, changed_only: bool) -> Dictionary:
894
+ var default_node = null
895
+ if changed_only:
896
+ default_node = instantiate_class(node.get_class())
897
+
898
+ var properties = {}
899
+ var property_list = node.get_property_list()
900
+
901
+ for prop in property_list:
902
+ var prop_name = prop["name"]
903
+ var prop_usage = prop["usage"]
904
+
905
+ if prop_usage & PROPERTY_USAGE_STORAGE or prop_usage & PROPERTY_USAGE_EDITOR:
906
+ var value = node.get(prop_name)
907
+
908
+ if default_node and default_node.get(prop_name) == value:
909
+ continue
910
+
911
+ if value is Vector2:
912
+ properties[prop_name] = {"x": value.x, "y": value.y}
913
+ elif value is Vector3:
914
+ properties[prop_name] = {"x": value.x, "y": value.y, "z": value.z}
915
+ elif value is Color:
916
+ properties[prop_name] = {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
917
+ elif value is Transform2D:
918
+ properties[prop_name] = str(value)
919
+ elif value is Transform3D:
920
+ properties[prop_name] = str(value)
921
+ elif value is Object:
922
+ if value:
923
+ properties[prop_name] = value.get_class()
924
+ else:
925
+ properties[prop_name] = null
926
+ elif typeof(value) in [TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_ARRAY, TYPE_DICTIONARY]:
927
+ properties[prop_name] = value
928
+ else:
929
+ properties[prop_name] = str(value)
930
+
931
+ if default_node:
932
+ default_node.free()
933
+
934
+ return properties
935
+
936
+ # Helper: validate a single target dict (script_path or scene_path)
937
+ func _validate_single(target: Dictionary) -> Dictionary:
938
+ if target.has("script_path") and target.script_path != "":
939
+ var path = normalize_scene_path(target.script_path)
940
+ if not FileAccess.file_exists(path):
941
+ return {"valid": false, "errors": [{"message": "File not found: " + path}], "target": target.script_path}
942
+ var resource = load(path)
943
+ # Actual parse errors go to stderr and are parsed by TypeScript
944
+ return {"valid": resource != null, "errors": [], "target": target.script_path}
945
+ elif target.has("scene_path") and target.scene_path != "":
946
+ var path = normalize_scene_path(target.scene_path)
947
+ if not FileAccess.file_exists(path):
948
+ return {"valid": false, "errors": [{"message": "File not found: " + path}], "target": target.scene_path}
949
+ var scene = load(path)
950
+ return {"valid": scene != null, "errors": [], "target": target.scene_path}
951
+ else:
952
+ return {"valid": false, "errors": [{"message": "No valid target: provide script_path or scene_path"}], "target": ""}
953
+
954
+ # Validate multiple scripts/scenes in a single headless process
955
+ func validate_batch(params: Dictionary) -> void:
956
+ var results: Array = []
957
+ for target in params.targets:
958
+ results.append(_validate_single(target))
959
+ print(JSON.stringify({"results": results}))
960
+
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
+ # Execute multiple scene operations in a single headless process
1002
+ # Scenes are loaded once and cached in memory; mutations accumulate until a save op
1003
+ func batch_scene_operations(params: Dictionary) -> void:
1004
+ var abort_on_error = params.get("abort_on_error", false)
1005
+ var results: Array = []
1006
+ var scene_cache: Dictionary = {}
1007
+
1008
+ for op in params.operations:
1009
+ var op_name = op.get("operation", "")
1010
+ var scene_path = op.get("scene_path", "")
1011
+ var result = {"operation": op_name, "scenePath": scene_path}
1012
+
1013
+ if scene_path != "" and scene_path not in scene_cache:
1014
+ var scene_root = load_scene_instance(scene_path)
1015
+ if scene_root:
1016
+ scene_cache[scene_path] = scene_root
1017
+ else:
1018
+ result["error"] = "Failed to load scene: " + scene_path
1019
+ results.append(result)
1020
+ if abort_on_error:
1021
+ break
1022
+ continue
1023
+
1024
+ var scene_root = scene_cache.get(scene_path, null) if scene_path != "" else null
1025
+
1026
+ match op_name:
1027
+ "add_node":
1028
+ if scene_root == null:
1029
+ result["error"] = "scene_path required for add_node"
1030
+ else:
1031
+ var err = _batch_add_node(scene_root, op)
1032
+ if err != "":
1033
+ result["error"] = err
1034
+ else:
1035
+ result["success"] = true
1036
+ "load_sprite":
1037
+ if scene_root == null:
1038
+ result["error"] = "scene_path required for load_sprite"
1039
+ else:
1040
+ var err = _batch_load_sprite(scene_root, op)
1041
+ if err != "":
1042
+ result["error"] = err
1043
+ else:
1044
+ result["success"] = true
1045
+ "save":
1046
+ if scene_root == null:
1047
+ result["error"] = "scene_path required for save"
1048
+ else:
1049
+ var new_path = op.get("new_path", scene_path)
1050
+ if save_scene_to_path(scene_root, new_path):
1051
+ result["success"] = true
1052
+ # Only evict on normal save; save-as leaves the mutated scene in
1053
+ # cache so subsequent ops on scene_path still see accumulated mutations.
1054
+ if new_path == scene_path:
1055
+ scene_cache.erase(scene_path)
1056
+ else:
1057
+ result["error"] = "Failed to save scene: " + scene_path
1058
+ _:
1059
+ result["error"] = "Unknown batch operation: " + op_name
1060
+
1061
+ results.append(result)
1062
+ if abort_on_error and result.has("error"):
1063
+ break
1064
+
1065
+ # Auto-save any scenes that were mutated but not explicitly saved
1066
+ for scene_path in scene_cache:
1067
+ save_scene_to_path(scene_cache[scene_path], scene_path)
1068
+
1069
+ 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}))