@beremaran/godot-agent-loop 1.0.1 → 1.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 (41) hide show
  1. package/README.md +59 -34
  2. package/addons/godot_agent_loop/README.md +26 -16
  3. package/addons/godot_agent_loop/plugin.cfg +3 -2
  4. package/addons/godot_agent_loop/plugin.gd +840 -25
  5. package/agent-plugin/.claude-plugin/plugin.json +1 -1
  6. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  7. package/agent-plugin/.mcp.json +1 -1
  8. package/agent-plugin/adapter-manifest.json +3 -3
  9. package/agent-plugin/pi/extension.ts +1 -1
  10. package/agent-plugin/skills/build-godot-game/SKILL.md +26 -5
  11. package/agent-plugin/skills/debug-godot-game/SKILL.md +20 -7
  12. package/agent-plugin/skills/ship-godot-game/SKILL.md +13 -3
  13. package/agent-plugin/skills/verify-godot-change/SKILL.md +17 -2
  14. package/build/authoring-session-manager.js +24 -1
  15. package/build/domain-tool-registries.js +5 -1
  16. package/build/editor-authoring-router.js +228 -0
  17. package/build/editor-bridge-protocol.js +2 -1
  18. package/build/editor-connection.js +29 -24
  19. package/build/editor-mutation-guard.js +3 -1
  20. package/build/editor-plugin-installer.js +2 -1
  21. package/build/editor-session-registry.js +392 -0
  22. package/build/editor-sync-queue.js +135 -0
  23. package/build/index.js +208 -29
  24. package/build/lifecycle-trace.js +80 -0
  25. package/build/scripts/mcp_editor_plugin.gd +840 -25
  26. package/build/scripts/mcp_interaction_server.gd +76 -2
  27. package/build/scripts/mcp_runtime/core_domain.gd +2 -1
  28. package/build/scripts/mcp_runtime/system_domain.gd +2 -0
  29. package/build/server-instructions.js +5 -5
  30. package/build/session-timing.js +17 -1
  31. package/build/tool-definitions.js +99 -6
  32. package/build/tool-handlers/game-tool-handlers.js +21 -4
  33. package/build/tool-handlers/lifecycle-tool-handlers.js +361 -27
  34. package/build/tool-handlers/project-handler-services.js +45 -6
  35. package/build/tool-handlers/project-tool-handlers.js +4 -4
  36. package/build/tool-manifest.js +29 -1
  37. package/build/tool-mutation-policy.js +1 -0
  38. package/build/tool-surface.js +4 -4
  39. package/build/utils.js +2 -2
  40. package/package.json +4 -3
  41. package/product.json +7 -5
@@ -8,8 +8,12 @@ extends EditorPlugin
8
8
  var _server: TCPServer
9
9
  var _peer: StreamPeerTCP
10
10
  var _buffer: PackedByteArray = PackedByteArray()
11
- var _port: int = 9091
11
+ var _port: int = 0
12
12
  var _secret: String = ""
13
+ var _project_path: String = ""
14
+ var _editor_pid: int = 0
15
+ var _editor_start_identity: String = ""
16
+ var _session_path: String = ""
13
17
  var _activity_dock: VBoxContainer
14
18
  var _activity_list: ItemList
15
19
  var _connection_status: Label
@@ -21,29 +25,51 @@ var _session_authenticated: bool = false
21
25
  var _server_version: String = ""
22
26
  var _last_history_id: int = EditorUndoRedoManager.GLOBAL_HISTORY
23
27
  var _activity_entries: Array[Dictionary] = []
28
+ var _activity_event_ids: Dictionary = {}
24
29
  var _last_filesystem_sync: Dictionary = {}
30
+ var _saved_history_versions: Dictionary = {}
25
31
  const MAX_ACTIVITY_ENTRIES: int = 200
26
- const PROTOCOL_VERSION: String = "1"
27
- const ADDON_VERSION: String = "1.0.1"
32
+ const PROTOCOL_VERSION: String = "2"
33
+ const ADDON_VERSION: String = "1.1.0"
34
+ const SESSION_DIRECTORY: String = ".godot/godot_agent_loop"
35
+ const SESSION_FILE: String = "editor-session.json"
28
36
 
29
37
  func _enter_tree() -> void:
30
38
  set_process(true)
31
39
  _port = _read_port()
32
40
  _secret = OS.get_environment("GODOT_MCP_EDITOR_SECRET")
41
+ if _secret.is_empty():
42
+ _secret = Marshalls.raw_to_base64(Crypto.new().generate_random_bytes(32))
43
+ _project_path = ProjectSettings.globalize_path("res://").trim_suffix("/")
44
+ _editor_pid = OS.get_process_id()
45
+ _editor_start_identity = "%d-%d" % [_editor_pid, Time.get_ticks_usec()]
46
+ _session_path = _project_path.path_join(SESSION_DIRECTORY).path_join(SESSION_FILE)
33
47
  _driver_paused = OS.get_environment("GODOT_MCP_EDITOR_START_PAUSED").strip_edges().to_lower() in ["1", "true", "yes"]
34
48
  _server = TCPServer.new()
35
49
  _create_activity_dock()
50
+ var scene_changed_error: Error = scene_changed.connect(_on_scene_changed) as Error
51
+ if scene_changed_error != OK:
52
+ push_error("Godot Agent Loop could not observe scene changes: %s" % error_string(scene_changed_error))
53
+ var scene_saved_error: Error = scene_saved.connect(_on_scene_saved) as Error
54
+ if scene_saved_error != OK:
55
+ push_error("Godot Agent Loop could not observe scene saves: %s" % error_string(scene_saved_error))
36
56
  var error: int = _server.listen(_port, "127.0.0.1")
37
57
  if error != OK:
38
58
  push_error("Godot Agent Loop editor bridge could not listen on %d: %s" % [_port, error_string(error)])
39
59
  _set_connection_status("Unavailable — port %d: %s" % [_port, error_string(error)])
40
- elif _secret.is_empty():
41
- _set_connection_status("Waiting — relaunch the editor through Godot Agent Loop")
42
60
  else:
43
- _set_connection_status("Waiting for an authenticated agent")
61
+ _port = _server.get_local_port()
62
+ var discovery_error: Error = _write_discovery_record()
63
+ if discovery_error != OK:
64
+ push_error("Godot Agent Loop could not publish editor discovery: %s" % error_string(discovery_error))
65
+ _set_connection_status("Unavailable — discovery record failed")
66
+ _server.stop()
67
+ else:
68
+ _set_connection_status("Waiting for an authenticated agent")
44
69
 
45
70
  func _exit_tree() -> void:
46
71
  set_process(false)
72
+ _remove_owned_discovery_record()
47
73
  if _peer != null:
48
74
  _peer.disconnect_from_host()
49
75
  _peer = null
@@ -120,7 +146,7 @@ func _handle_request(line: String) -> void:
120
146
  if not _session_authenticated:
121
147
  _send({"id": request.get("id", null), "error": "handshake_required", "protocol_version": PROTOCOL_VERSION})
122
148
  return
123
- var result: Dictionary = _dispatch(command, request.get("params", {}))
149
+ var result: Dictionary = await _dispatch(command, request.get("params", {}))
124
150
  result["id"] = request.get("id", null)
125
151
  _send(result)
126
152
 
@@ -134,7 +160,11 @@ func _dispatch(command: String, raw_params: Variant) -> Dictionary:
134
160
  "driver_state":
135
161
  return _driver_state()
136
162
  "filesystem_changed":
137
- return _sync_filesystem(params)
163
+ return await _sync_filesystem(params)
164
+ "transaction":
165
+ return await _editor_transaction(params)
166
+ "resource_transaction":
167
+ return _editor_resource_transaction(params)
138
168
  "select":
139
169
  return _select(params)
140
170
  "save":
@@ -167,7 +197,7 @@ func _dispatch(command: String, raw_params: Variant) -> Dictionary:
167
197
  var redo_history: UndoRedo = redo_manager.get_history_undo_redo(_last_history_id)
168
198
  return {"success": redo_history.redo(), "action": "redo"}
169
199
  _:
170
- return {"error": "unknown_command", "allowed": ["inspect", "activity", "driver_state", "filesystem_changed", "select", "save", "reload", "open_scene", "set_property", "rename_node", "undo", "redo"]}
200
+ return {"error": "unknown_command", "allowed": ["inspect", "activity", "driver_state", "filesystem_changed", "transaction", "resource_transaction", "select", "save", "reload", "open_scene", "set_property", "rename_node", "undo", "redo"]}
171
201
 
172
202
  func _handshake(raw_params: Variant) -> Dictionary:
173
203
  var params: Dictionary = raw_params if raw_params is Dictionary else {}
@@ -191,6 +221,10 @@ func _handshake(raw_params: Variant) -> Dictionary:
191
221
  "addon_version": ADDON_VERSION,
192
222
  "server_version": _server_version,
193
223
  "godot_version": Engine.get_version_info().get("string", "unknown"),
224
+ "project_path": _project_path,
225
+ "editor_pid": _editor_pid,
226
+ "editor_start_identity": _editor_start_identity,
227
+ "paused": _driver_paused,
194
228
  }
195
229
 
196
230
  func _create_activity_dock() -> void:
@@ -233,7 +267,7 @@ func _create_activity_dock() -> void:
233
267
  setup_help.name = "SetupHelp"
234
268
  setup_help.fit_content = true
235
269
  setup_help.custom_minimum_size = Vector2(320, 96)
236
- setup_help.text = "Claude Code / Codex: install the Godot Agent Loop plugin\nOpenCode: godot-agent-loop setup opencode --write\nPi: pi install npm:@beremaran/godot-agent-loop\nRelaunch the editor through the MCP launch_editor tool to authenticate."
270
+ setup_help.text = "Install and enable this addon once for interactive projects.\nOpen Godot normally before or after the MCP; this dock waits for the matching authenticated agent.\nUse launch_editor only when no editor is already open."
237
271
  _activity_dock.add_child(setup_help)
238
272
  add_control_to_dock(DOCK_SLOT_RIGHT_BL, _activity_dock)
239
273
 
@@ -256,11 +290,12 @@ func _driver_state() -> Dictionary:
256
290
  return {"success": true, "paused": _driver_paused, "agent_driving": not _driver_paused}
257
291
 
258
292
  func _record_activity(params: Dictionary) -> Dictionary:
259
- var event: String = str(params.get("event", ""))
260
- if not event in ["request_started", "request_finished", "request_timed_out"]:
261
- return {"error": "invalid_activity_event"}
262
- var command: String = str(params.get("command", "unknown"))
263
- var target: String = str(params.get("target", "game"))
293
+ var event_id: int = _variant_int(params.get("event_id", 0))
294
+ if event_id > 0 and _activity_event_ids.has(event_id):
295
+ return {"success": true, "deduplicated": true, "event_id": event_id, "activity_count": _activity_entries.size()}
296
+ var event: String = str(params.get("event", params.get("phase", "state")))
297
+ var command: String = str(params.get("command", params.get("tool", "unknown")))
298
+ var target: String = str(params.get("target", params.get("target_backend", "unknown")))
264
299
  var outcome: String = str(params.get("outcome", "running"))
265
300
  var raw_duration_ms: Variant = params.get("duration_ms", 0)
266
301
  var duration_ms: int = 0
@@ -270,28 +305,66 @@ func _record_activity(params: Dictionary) -> Dictionary:
270
305
  var float_duration_ms: float = raw_duration_ms
271
306
  duration_ms = roundi(float_duration_ms)
272
307
  var correlation_id: String = str(params.get("correlation_id", ""))
273
- var marker: String = "…" if event == "request_started" else "✓" if outcome == "success" else ""
274
- var suffix: String = "" if event == "request_started" else " · %d ms" % duration_ms
308
+ var running: bool = outcome == "running" or event in ["request_started", "start"]
309
+ var marker: String = "" if running else "✓" if outcome == "success" else "↪" if outcome == "fallback" else "Ⅱ" if outcome == "paused" else "!" if outcome == "conflict" else "✗"
310
+ var suffix: String = "" if running else " · %d ms" % duration_ms
275
311
  var text: String = "%s %s → %s%s" % [marker, command, target, suffix]
276
312
  var entry: Dictionary = {
277
- "event": event, "correlation_id": correlation_id, "command": command,
313
+ "event_id": event_id, "event": event, "correlation_id": correlation_id, "command": command,
278
314
  "target": target, "outcome": outcome, "duration_ms": duration_ms, "text": text,
279
315
  }
280
316
  _activity_entries.append(entry)
317
+ if event_id > 0: _activity_event_ids[event_id] = true
281
318
  var item_index: int = _activity_list.add_item(text)
282
319
  _activity_list.set_item_tooltip(item_index, correlation_id)
283
- if event != "request_started":
284
- var color := Color(0.35, 0.85, 0.45) if outcome == "success" else Color(1.0, 0.4, 0.35)
320
+ if not running:
321
+ var color := Color(0.35, 0.85, 0.45) if outcome == "success" else Color(1.0, 0.75, 0.25) if outcome in ["fallback", "paused", "conflict"] else Color(1.0, 0.4, 0.35)
285
322
  _activity_list.set_item_custom_fg_color(item_index, color)
323
+ if outcome == "paused": _set_connection_status("Paused — human editing")
324
+ elif outcome == "conflict": _set_connection_status("Conflict — unsaved changes preserved")
286
325
  while _activity_entries.size() > MAX_ACTIVITY_ENTRIES:
287
- _activity_entries.pop_front()
326
+ var removed: Dictionary = _activity_entries.pop_front()
327
+ var removed_id: int = _variant_int(removed.get("event_id", 0))
328
+ if removed_id > 0:
329
+ @warning_ignore("return_value_discarded")
330
+ _activity_event_ids.erase(removed_id)
288
331
  _activity_list.remove_item(0)
289
332
  return {"success": true, "activity_count": _activity_entries.size()}
290
333
 
291
334
  func _sync_filesystem(params: Dictionary) -> Dictionary:
292
335
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
293
- filesystem.scan()
294
336
  var scene_path: String = str(params.get("scene_path", ""))
337
+ var resource_path: String = str(params.get("resource_path", ""))
338
+ var root_before: Node = EditorInterface.get_edited_scene_root()
339
+ var open_scene_before: String = "" if root_before == null else root_before.scene_file_path
340
+ var selection_before: Array[String] = []
341
+ for selected_node: Node in EditorInterface.get_selection().get_selected_nodes():
342
+ selection_before.append(str(selected_node.get_path()))
343
+ if root_before != null and not scene_path.is_empty() and root_before.scene_file_path == scene_path and _scene_has_unsaved_changes(root_before):
344
+ _last_filesystem_sync = {
345
+ "success": false, "error": "unsaved_conflict", "state": "unsaved_conflict",
346
+ "scene_path": scene_path, "resource_path": resource_path,
347
+ "observed_target_state": {
348
+ "edited_scene": open_scene_before, "selection": selection_before,
349
+ "unsaved": true, "reloaded": false,
350
+ },
351
+ }
352
+ _set_connection_status("Conflict — unsaved editor changes preserved")
353
+ return _last_filesystem_sync.duplicate(true)
354
+ _set_connection_status("Synchronizing filesystem")
355
+ filesystem.scan()
356
+ var frames_waited: int = 0
357
+ while filesystem.is_scanning() and frames_waited < 600:
358
+ await get_tree().process_frame
359
+ frames_waited += 1
360
+ if filesystem.is_scanning():
361
+ _last_filesystem_sync = {
362
+ "success": false, "error": "sync_timeout", "state": "syncing",
363
+ "scene_path": scene_path, "resource_path": resource_path,
364
+ "observed_target_state": {"scan_complete": false, "frames_waited": frames_waited},
365
+ }
366
+ _set_connection_status("Connected — filesystem synchronization timed out")
367
+ return _last_filesystem_sync.duplicate(true)
295
368
  var root: Node = EditorInterface.get_edited_scene_root()
296
369
  var reloaded: bool = false
297
370
  if root != null and not scene_path.is_empty() and root.scene_file_path == scene_path:
@@ -301,14 +374,75 @@ func _sync_filesystem(params: Dictionary) -> Dictionary:
301
374
  var focused: bool = false
302
375
  if reloaded and not focus_path.is_empty():
303
376
  focused = _focus_editor_node(focus_path)
377
+ var root_after: Node = EditorInterface.get_edited_scene_root()
378
+ var selection_after: Array[String] = []
379
+ for selected_node: Node in EditorInterface.get_selection().get_selected_nodes():
380
+ selection_after.append(str(selected_node.get_path()))
381
+ var scene_readback: Dictionary = _readback_sync_target(scene_path, true)
382
+ var resource_readback: Dictionary = _readback_sync_target(resource_path, false)
383
+ var scene_visible: bool = scene_readback.get("readable", false) == true
384
+ var resource_visible: bool = resource_readback.get("readable", false) == true
385
+ var target_readable: bool = scene_visible and resource_visible
386
+ var observed_target_state: Dictionary = {
387
+ "scan_complete": true,
388
+ "frames_waited": frames_waited,
389
+ "edited_scene": "" if root_after == null else root_after.scene_file_path,
390
+ "selection": selection_after,
391
+ "scene_visible": scene_visible,
392
+ "resource_visible": resource_visible,
393
+ "scene_readback": scene_readback,
394
+ "resource_readback": resource_readback,
395
+ "independently_reopened": target_readable,
396
+ "reloaded": reloaded,
397
+ "focused": focused,
398
+ "preserved_context": {
399
+ "open_scene": open_scene_before == ("" if root_after == null else root_after.scene_file_path),
400
+ "selection_before": selection_before,
401
+ "viewport": "public_api_unavailable",
402
+ "inspector": "restored_to_focus_target" if focused else "unchanged_or_unavailable",
403
+ },
404
+ }
304
405
  _last_filesystem_sync = {
305
- "success": true, "rescanned": true, "scene_path": scene_path,
306
- "resource_path": str(params.get("resource_path", "")), "reloaded": reloaded,
406
+ "success": target_readable, "rescanned": true, "scene_path": scene_path,
407
+ "resource_path": resource_path, "reloaded": reloaded,
307
408
  "command": str(params.get("command", "")), "focus_path": focus_path,
308
- "focused": focused,
409
+ "focused": focused, "state": "connected",
410
+ "observed_target_state": observed_target_state,
309
411
  }
412
+ if not target_readable:
413
+ _last_filesystem_sync["error"] = "target_not_editor_readable"
414
+ _last_filesystem_sync["state"] = "target_unreadable"
415
+ _set_connection_status("Connected — synchronized target is unreadable")
416
+ else:
417
+ _set_connection_status("Authenticated — synchronized")
310
418
  return _last_filesystem_sync.duplicate(true)
311
419
 
420
+ func _readback_sync_target(resource_path: String, require_packed_scene: bool) -> Dictionary:
421
+ if resource_path.is_empty():
422
+ return {"requested": false, "exists": true, "readable": true, "reader": "none"}
423
+ var absolute_path: String = ProjectSettings.globalize_path(resource_path)
424
+ var file_exists: bool = FileAccess.file_exists(absolute_path)
425
+ var extension: String = resource_path.get_extension().to_lower()
426
+ var recognized_extensions: PackedStringArray = ResourceLoader.get_recognized_extensions_for_type("Resource")
427
+ var resource_format: bool = require_packed_scene or ResourceLoader.exists(resource_path) or extension in recognized_extensions
428
+ if resource_format:
429
+ var loaded: Resource = null
430
+ if file_exists:
431
+ loaded = ResourceLoader.load(resource_path, "PackedScene" if require_packed_scene else "", ResourceLoader.CACHE_MODE_IGNORE)
432
+ var resource_readable: bool = loaded is PackedScene if require_packed_scene else loaded != null
433
+ return {
434
+ "requested": true, "exists": file_exists, "readable": resource_readable,
435
+ "reader": "resource_loader", "resource_type": "" if loaded == null else loaded.get_class(),
436
+ }
437
+ var file: FileAccess = FileAccess.open(absolute_path, FileAccess.READ) if file_exists else null
438
+ var file_readable: bool = file != null
439
+ if file != null:
440
+ file.close()
441
+ return {
442
+ "requested": true, "exists": file_exists, "readable": file_readable,
443
+ "reader": "file_access", "resource_type": "",
444
+ }
445
+
312
446
  func _inspect() -> Dictionary:
313
447
  var root: Node = EditorInterface.get_edited_scene_root()
314
448
  var selected: Array[String] = []
@@ -402,6 +536,573 @@ func _commit_property_action(target: Node, property: String, before: Variant, af
402
536
  _last_history_id = manager.get_object_history_id(target)
403
537
  return {"success": true, "property": property, "before": before, "after": target.get(property), "undo_recorded": true}
404
538
 
539
+ func _editor_transaction(params: Dictionary) -> Dictionary:
540
+ if _driver_paused:
541
+ return {"error": "paused", "state": "paused"}
542
+ var scene_path: String = _resource_path(str(params.get("scene_path", "")))
543
+ var action_name: String = str(params.get("name", "Godot Agent Loop transaction")).strip_edges()
544
+ var operations_variant: Variant = params.get("operations", [])
545
+ if scene_path.is_empty() or action_name.is_empty() or not operations_variant is Array:
546
+ return {"error": "scene_path, name, and operations are required"}
547
+ var operations: Array = operations_variant
548
+ if operations.is_empty() or operations.size() > 256:
549
+ return {"error": "operations must contain 1 to 256 items"}
550
+ var created_scene: bool = false
551
+ if not ResourceLoader.exists(scene_path):
552
+ var root_type: String = str(params.get("root_type", "Node"))
553
+ if not ClassDB.is_parent_class(root_type, "Node"):
554
+ return {"error": "invalid_root_type", "root_type": root_type}
555
+ var new_root_variant: Variant = ClassDB.instantiate(root_type)
556
+ if not new_root_variant is Node:
557
+ return {"error": "root_type_not_instantiable", "root_type": root_type}
558
+ var new_root: Node = new_root_variant
559
+ new_root.name = scene_path.get_file().get_basename().to_pascal_case()
560
+ var preflight: Dictionary = _validate_transaction(new_root, operations)
561
+ if preflight.has("error"):
562
+ new_root.free()
563
+ return preflight
564
+ _discard_transaction_stages(preflight.get("stages", []))
565
+ var initial_scene := PackedScene.new()
566
+ var pack_error: Error = initial_scene.pack(new_root)
567
+ if pack_error != OK:
568
+ new_root.free()
569
+ return {"error": "create_scene_pack_failed", "error_code": pack_error}
570
+ var directory_error: Error = _ensure_resource_parent_directory(scene_path)
571
+ if directory_error != OK:
572
+ new_root.free()
573
+ return {"error": "create_scene_directory_failed", "error_code": directory_error}
574
+ var create_error: Error = ResourceSaver.save(initial_scene, scene_path)
575
+ new_root.free()
576
+ if create_error != OK:
577
+ return {"error": "create_scene_save_failed", "error_code": create_error}
578
+ created_scene = true
579
+ EditorInterface.open_scene_from_path(scene_path)
580
+ var open_frames: int = 0
581
+ while open_frames < 120:
582
+ var candidate: Node = EditorInterface.get_edited_scene_root()
583
+ if candidate != null and candidate.scene_file_path == scene_path:
584
+ break
585
+ await get_tree().process_frame
586
+ open_frames += 1
587
+ var root: Node = EditorInterface.get_edited_scene_root()
588
+ if root == null or root.scene_file_path != scene_path:
589
+ return _rollback_created_transaction({"error": "scene_open_failed", "scene_path": scene_path}, scene_path, created_scene)
590
+ if _scene_has_unsaved_changes(root):
591
+ return _rollback_created_transaction({"error": "unsaved_conflict", "state": "unsaved_conflict", "scene_path": scene_path}, scene_path, created_scene)
592
+ var validation: Dictionary = _validate_transaction(root, operations)
593
+ if validation.has("error"):
594
+ return _rollback_created_transaction(validation, scene_path, created_scene)
595
+ var stages: Array = validation.get("stages", [])
596
+ var undo_recorded: bool = not stages.is_empty()
597
+ if undo_recorded:
598
+ var manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
599
+ if manager == null:
600
+ _discard_transaction_stages(stages)
601
+ return _rollback_created_transaction({"error": "undo_redo_unavailable"}, scene_path, created_scene)
602
+ manager.create_action(action_name)
603
+ for stage_variant: Variant in stages:
604
+ var stage: Dictionary = stage_variant
605
+ _apply_transaction_stage(manager, root, stage)
606
+ manager.commit_action()
607
+ _last_history_id = manager.get_object_history_id(root)
608
+ var focus_path: String = str(params.get("focus_path", validation.get("focus_path", "")))
609
+ var focused: bool = false
610
+ if not focus_path.is_empty():
611
+ focused = _focus_editor_node(focus_path)
612
+ var saved: bool = false
613
+ var save_error: Error = OK
614
+ if _variant_bool(params.get("save", true)):
615
+ save_error = EditorInterface.save_scene()
616
+ saved = save_error == OK
617
+ if save_error != OK:
618
+ return _rollback_created_transaction({"error": "scene_save_failed", "error_code": save_error, "undo_recorded": undo_recorded}, scene_path, created_scene)
619
+ var persisted: Dictionary = _independent_scene_readback(scene_path)
620
+ if persisted.has("error"):
621
+ return _rollback_created_transaction({"error": "independent_readback_failed", "details": persisted, "undo_recorded": undo_recorded}, scene_path, created_scene)
622
+ return {
623
+ "success": true,
624
+ "backend": "editor",
625
+ "transaction_name": action_name,
626
+ "operation_count": operations.size(),
627
+ "undo_recorded": undo_recorded,
628
+ "saved": saved,
629
+ "scene_created": created_scene,
630
+ "creation_fallback": _creation_fallback(created_scene, "initial PackedScene save required before opening"),
631
+ "focused": focused,
632
+ "focus_path": focus_path,
633
+ "observed_target_state": persisted,
634
+ }
635
+
636
+ func _rollback_created_transaction(result: Dictionary, scene_path: String, created_scene: bool) -> Dictionary:
637
+ if not created_scene:
638
+ return result
639
+ var remove_error: Error = DirAccess.remove_absolute(ProjectSettings.globalize_path(scene_path))
640
+ result["created_scene_rolled_back"] = remove_error in [OK, ERR_DOES_NOT_EXIST]
641
+ if remove_error not in [OK, ERR_DOES_NOT_EXIST]:
642
+ result["rollback_error_code"] = remove_error
643
+ return result
644
+
645
+ func _discard_transaction_stages(stages_variant: Variant) -> void:
646
+ if not stages_variant is Array:
647
+ return
648
+ var nodes: Array[Node] = []
649
+ for stage_variant: Variant in stages_variant:
650
+ if not stage_variant is Dictionary:
651
+ continue
652
+ var stage: Dictionary = stage_variant
653
+ if str(stage.get("op", "")) not in ["add_node", "instantiate_scene", "duplicate_node"]:
654
+ continue
655
+ var node_variant: Variant = stage.get("node")
656
+ if not node_variant is Node:
657
+ continue
658
+ nodes.append(node_variant)
659
+ _discard_transaction_nodes(nodes)
660
+
661
+ func _discard_transaction_nodes(nodes: Array[Node]) -> void:
662
+ var discarded: Dictionary = {}
663
+ for node: Node in nodes:
664
+ var instance_id: int = node.get_instance_id()
665
+ if discarded.has(instance_id):
666
+ continue
667
+ discarded[instance_id] = true
668
+ if is_instance_valid(node) and node.get_parent() == null:
669
+ node.free()
670
+
671
+ func _validate_transaction(root: Node, operations: Array) -> Dictionary:
672
+ var allocated_nodes: Array[Node] = []
673
+ var validation: Dictionary = _validate_transaction_operations(root, operations, allocated_nodes)
674
+ if validation.has("error"):
675
+ _discard_transaction_nodes(allocated_nodes)
676
+ return validation
677
+
678
+ func _validate_transaction_operations(root: Node, operations: Array, allocated_nodes: Array[Node]) -> Dictionary:
679
+ var stages: Array[Dictionary] = []
680
+ var virtual_nodes: Dictionary = {}
681
+ var virtual_paths: Dictionary = {}
682
+ var staged_node_ids: Dictionary = {}
683
+ _index_transaction_subtree(root, ".", virtual_nodes, virtual_paths)
684
+ var virtual_root_name: String = str(root.name)
685
+ var focus_path: String = ""
686
+ for index: int in operations.size():
687
+ var operation_variant: Variant = operations[index]
688
+ if not operation_variant is Dictionary:
689
+ return {"error": "invalid_operation", "operation_index": index}
690
+ var operation: Dictionary = operation_variant
691
+ var op: String = str(operation.get("op", ""))
692
+ if op == "save":
693
+ continue
694
+ if op == "add_node":
695
+ var parent_path: String = str(operation.get("parent_path", "."))
696
+ var parent: Node = _transaction_node(root, parent_path, virtual_nodes)
697
+ var node_type: String = str(operation.get("node_type", "Node"))
698
+ var node_name: String = str(operation.get("node_name", node_type)).strip_edges()
699
+ if parent == null: return {"error": "parent_not_found", "operation_index": index, "parent_path": parent_path}
700
+ if node_name.is_empty() or node_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
701
+ var normalized_parent_path: String = str(virtual_paths.get(parent.get_instance_id(), ""))
702
+ var node_path: String = _joined_node_path(normalized_parent_path, node_name)
703
+ if virtual_nodes.has(node_path): return {"error": "duplicate_node_name", "operation_index": index, "node_name": node_name}
704
+ if not ClassDB.is_parent_class(node_type, "Node"): return {"error": "invalid_node_type", "operation_index": index, "node_type": node_type}
705
+ var node_variant: Variant = ClassDB.instantiate(node_type)
706
+ if not node_variant is Node: return {"error": "node_type_not_instantiable", "operation_index": index}
707
+ var node: Node = node_variant
708
+ allocated_nodes.append(node)
709
+ node.name = node_name
710
+ var properties_variant: Variant = operation.get("properties", {})
711
+ if not properties_variant is Dictionary: return {"error": "properties_must_be_object", "operation_index": index}
712
+ for property_name_variant: Variant in properties_variant:
713
+ var property_name: String = str(property_name_variant)
714
+ if not property_name in node: return {"error": "property_not_found", "operation_index": index, "property": property_name}
715
+ var decoded: Dictionary = _decode_editor_value(properties_variant[property_name_variant], node.get(property_name))
716
+ if decoded.has("error"): return {"error": decoded.error, "operation_index": index, "property": property_name}
717
+ node.set(property_name, decoded.value)
718
+ _index_transaction_subtree(node, node_path, virtual_nodes, virtual_paths, staged_node_ids, true)
719
+ stages.append({"op": op, "parent": parent, "node": node})
720
+ focus_path = node_path
721
+ elif op == "instantiate_scene":
722
+ var instance_parent_path: String = str(operation.get("parent_path", "."))
723
+ var instance_parent: Node = _transaction_node(root, instance_parent_path, virtual_nodes)
724
+ var packed_path: String = _resource_path(str(operation.get("scene_path", "")))
725
+ var packed: Resource = ResourceLoader.load(packed_path, "PackedScene")
726
+ if instance_parent == null or not packed is PackedScene:
727
+ return {"error": "packed_scene_or_parent_invalid", "operation_index": index}
728
+ var packed_scene: PackedScene = packed
729
+ var instance: Node = packed_scene.instantiate()
730
+ allocated_nodes.append(instance)
731
+ if operation.has("node_name"): instance.name = str(operation.node_name)
732
+ var instance_name: String = str(instance.name).strip_edges()
733
+ if instance_name.is_empty() or instance_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
734
+ var normalized_instance_parent_path: String = str(virtual_paths.get(instance_parent.get_instance_id(), ""))
735
+ var instance_path: String = _joined_node_path(normalized_instance_parent_path, instance_name)
736
+ if virtual_nodes.has(instance_path): return {"error": "duplicate_node_name", "operation_index": index, "node_name": instance_name}
737
+ _index_transaction_subtree(instance, instance_path, virtual_nodes, virtual_paths, staged_node_ids, true)
738
+ stages.append({"op": op, "parent": instance_parent, "node": instance})
739
+ focus_path = instance_path
740
+ else:
741
+ var node_path: String = str(operation.get("node_path", "."))
742
+ var target: Node = _transaction_node(root, node_path, virtual_nodes)
743
+ if target == null: return {"error": "node_not_found", "operation_index": index, "node_path": node_path}
744
+ if target != root and target.owner != root and not staged_node_ids.has(target.get_instance_id()):
745
+ return {"error": "inherited_or_noneditable_node", "operation_index": index, "node_path": node_path}
746
+ var current_path: String = str(virtual_paths.get(target.get_instance_id(), ""))
747
+ if op == "remove_node":
748
+ if target == root: return {"error": "cannot_remove_scene_root", "operation_index": index}
749
+ var current_parent: Node = _transaction_node(root, current_path.get_base_dir(), virtual_nodes)
750
+ stages.append({"op": op, "node": target, "parent": current_parent, "index": target.get_index(), "owner": target.owner})
751
+ _remove_transaction_subtree(target, virtual_nodes, virtual_paths)
752
+ elif op == "rename_node":
753
+ var new_name: String = str(operation.get("name", operation.get("node_name", ""))).strip_edges()
754
+ if new_name.is_empty() or new_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
755
+ var renamed_path: String = current_path if target == root else _joined_node_path(current_path.get_base_dir(), new_name)
756
+ if target != root and virtual_nodes.has(renamed_path) and virtual_nodes[renamed_path] != target:
757
+ return {"error": "duplicate_node_name", "operation_index": index, "node_name": new_name}
758
+ var before_name: String = virtual_root_name if target == root else current_path.get_file()
759
+ stages.append({"op": op, "node": target, "before": before_name, "after": new_name})
760
+ if target == root:
761
+ virtual_root_name = new_name
762
+ else:
763
+ _move_transaction_subtree(target, renamed_path, virtual_nodes, virtual_paths)
764
+ focus_path = renamed_path
765
+ elif op == "duplicate_node":
766
+ if target == root: return {"error": "cannot_duplicate_scene_root", "operation_index": index}
767
+ var duplicate_name: String = str(operation.get("node_name", "%sCopy" % current_path.get_file())).strip_edges()
768
+ if duplicate_name.is_empty() or duplicate_name.contains("/"): return {"error": "invalid_node_name", "operation_index": index}
769
+ var duplicate_path: String = _joined_node_path(current_path.get_base_dir(), duplicate_name)
770
+ if virtual_nodes.has(duplicate_path): return {"error": "duplicate_node_name", "operation_index": index, "node_name": duplicate_name}
771
+ var duplicated_node: Node = target.duplicate(Node.DUPLICATE_USE_INSTANTIATION)
772
+ allocated_nodes.append(duplicated_node)
773
+ duplicated_node.name = duplicate_name
774
+ var duplicate_parent: Node = _transaction_node(root, current_path.get_base_dir(), virtual_nodes)
775
+ _index_transaction_subtree(duplicated_node, duplicate_path, virtual_nodes, virtual_paths, staged_node_ids, true)
776
+ stages.append({"op": op, "node": duplicated_node, "parent": duplicate_parent})
777
+ focus_path = duplicate_path
778
+ elif op == "reparent_node":
779
+ if target == root: return {"error": "cannot_reparent_scene_root", "operation_index": index}
780
+ var new_parent_path: String = str(operation.get("new_parent_path", ""))
781
+ var new_parent: Node = _transaction_node(root, new_parent_path, virtual_nodes)
782
+ if new_parent == null: return {"error": "invalid_reparent_target", "operation_index": index}
783
+ var normalized_new_parent_path: String = str(virtual_paths.get(new_parent.get_instance_id(), ""))
784
+ if new_parent == target or normalized_new_parent_path.begins_with(current_path + "/"):
785
+ return {"error": "invalid_reparent_target", "operation_index": index}
786
+ var reparented_path: String = _joined_node_path(normalized_new_parent_path, current_path.get_file())
787
+ if virtual_nodes.has(reparented_path) and virtual_nodes[reparented_path] != target:
788
+ return {"error": "duplicate_node_name", "operation_index": index, "node_name": current_path.get_file()}
789
+ var before_parent: Node = _transaction_node(root, current_path.get_base_dir(), virtual_nodes)
790
+ stages.append({"op": op, "node": target, "before_parent": before_parent, "after_parent": new_parent, "keep_global": _variant_bool(operation.get("keep_global_transform", true))})
791
+ _move_transaction_subtree(target, reparented_path, virtual_nodes, virtual_paths)
792
+ focus_path = reparented_path
793
+ elif op == "set_properties":
794
+ var set_properties_variant: Variant = operation.get("properties", {})
795
+ if not set_properties_variant is Dictionary: return {"error": "properties_must_be_object", "operation_index": index}
796
+ for property_variant: Variant in set_properties_variant:
797
+ var property_name: String = str(property_variant)
798
+ if not property_name in target: return {"error": "property_not_found", "operation_index": index, "property": property_name}
799
+ var decoded: Dictionary = _decode_editor_value(set_properties_variant[property_variant], target.get(property_name))
800
+ if decoded.has("error"): return {"error": decoded.error, "operation_index": index, "property": property_name}
801
+ stages.append({"op": "set_property", "node": target, "property": property_name, "before": target.get(property_name), "after": decoded.value})
802
+ focus_path = current_path
803
+ elif op == "attach_script":
804
+ var script: Resource = ResourceLoader.load(_resource_path(str(operation.get("script_path", ""))), "Script")
805
+ if not script is Script: return {"error": "script_not_found", "operation_index": index}
806
+ stages.append({"op": "set_property", "node": target, "property": "script", "before": target.get_script(), "after": script})
807
+ focus_path = current_path
808
+ elif op == "assign_resource":
809
+ var resource_property: String = str(operation.get("property", ""))
810
+ var resource: Resource = ResourceLoader.load(_resource_path(str(operation.get("resource_path", ""))))
811
+ if resource_property.is_empty() or not resource_property in target or resource == null: return {"error": "resource_or_property_invalid", "operation_index": index}
812
+ stages.append({"op": "set_property", "node": target, "property": resource_property, "before": target.get(resource_property), "after": resource})
813
+ focus_path = current_path
814
+ else:
815
+ return {"error": "unsupported_operation", "operation_index": index, "op": op}
816
+ return {"stages": stages, "focus_path": focus_path}
817
+
818
+ func _editor_resource_transaction(params: Dictionary) -> Dictionary:
819
+ if _driver_paused:
820
+ return {"error": "paused", "state": "paused"}
821
+ var resource_path: String = _resource_path(str(params.get("resource_path", "")))
822
+ var resource_type: String = str(params.get("resource_type", ""))
823
+ var properties_variant: Variant = params.get("properties", {})
824
+ if resource_path.is_empty() or not properties_variant is Dictionary:
825
+ return {"error": "resource_path and object properties are required"}
826
+ var properties: Dictionary = properties_variant
827
+ var resource: Resource
828
+ var created: bool = false
829
+ if ResourceLoader.exists(resource_path):
830
+ resource = ResourceLoader.load(resource_path)
831
+ if resource == null:
832
+ return {"error": "resource_load_failed", "resource_path": resource_path}
833
+ else:
834
+ if not _is_supported_editor_resource_type(resource_type):
835
+ return {"error": "unsupported_resource_type", "resource_type": resource_type}
836
+ var instance: Variant = ClassDB.instantiate(resource_type)
837
+ if not instance is Resource:
838
+ return {"error": "resource_type_not_instantiable", "resource_type": resource_type}
839
+ resource = instance
840
+ created = true
841
+ var stages: Array[Dictionary] = []
842
+ for property_variant: Variant in properties:
843
+ var property_name: String = str(property_variant)
844
+ if not property_name in resource:
845
+ return {"error": "property_not_found", "property": property_name}
846
+ var decoded: Dictionary = _decode_editor_value(properties[property_variant], resource.get(property_name))
847
+ if decoded.has("error"):
848
+ return {"error": decoded.error, "property": property_name}
849
+ stages.append({"property": property_name, "before": resource.get(property_name), "after": decoded.value})
850
+ var undo_recorded: bool = not created and not stages.is_empty()
851
+ if undo_recorded:
852
+ var manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
853
+ if manager == null:
854
+ return {"error": "undo_redo_unavailable"}
855
+ manager.create_action(str(params.get("name", "Modify resource")))
856
+ for stage: Dictionary in stages:
857
+ var property_name: StringName = StringName(str(stage.get("property", "")))
858
+ manager.add_do_property(resource, property_name, stage.get("after"))
859
+ manager.add_undo_property(resource, property_name, stage.get("before"))
860
+ manager.commit_action()
861
+ _last_history_id = manager.get_object_history_id(resource)
862
+ else:
863
+ for stage: Dictionary in stages:
864
+ var property_name: StringName = StringName(str(stage.get("property", "")))
865
+ resource.set(property_name, stage.get("after"))
866
+ var directory_error: Error = _ensure_resource_parent_directory(resource_path)
867
+ if directory_error != OK:
868
+ return {"error": "resource_directory_failed", "error_code": directory_error, "undo_recorded": undo_recorded}
869
+ var save_error: Error = ResourceSaver.save(resource, resource_path)
870
+ if save_error != OK:
871
+ return {"error": "resource_save_failed", "error_code": save_error, "undo_recorded": undo_recorded}
872
+ EditorInterface.edit_resource(resource)
873
+ var readback: Resource = ResourceLoader.load(resource_path, "", ResourceLoader.CACHE_MODE_IGNORE)
874
+ if readback == null:
875
+ return {"error": "independent_resource_readback_failed", "undo_recorded": undo_recorded}
876
+ var observed_properties: Dictionary = {}
877
+ for property_variant: Variant in properties:
878
+ var property_name: String = str(property_variant)
879
+ observed_properties[property_name] = str(readback.get(property_name))
880
+ return {
881
+ "success": true,
882
+ "backend": "editor",
883
+ "resource_path": resource_path,
884
+ "resource_type": readback.get_class(),
885
+ "resource_uid": ResourceLoader.get_resource_uid(resource_path),
886
+ "created": created,
887
+ "undo_recorded": undo_recorded,
888
+ "creation_fallback": _creation_fallback(created, "initial ResourceSaver save required before editor inspection"),
889
+ "focused": true,
890
+ "observed_target_state": {
891
+ "resource_path": resource_path,
892
+ "resource_type": readback.get_class(),
893
+ "properties": observed_properties,
894
+ "independently_reloaded": true,
895
+ },
896
+ }
897
+
898
+ func _is_supported_editor_resource_type(resource_type: String) -> bool:
899
+ if resource_type.is_empty() or not ClassDB.class_exists(resource_type) or not ClassDB.can_instantiate(resource_type):
900
+ return false
901
+ for base_type: String in ["BaseMaterial3D", "Mesh", "Shape2D", "Shape3D", "Theme", "AudioStream", "Gradient", "Curve", "Environment"]:
902
+ if ClassDB.is_parent_class(resource_type, base_type):
903
+ return true
904
+ return false
905
+
906
+ func _apply_transaction_stage(manager: EditorUndoRedoManager, root: Node, stage: Dictionary) -> void:
907
+ var op: String = str(stage.get("op", ""))
908
+ var stage_node_variant: Variant = stage.get("node")
909
+ if not stage_node_variant is Node:
910
+ return
911
+ var stage_node: Node = stage_node_variant
912
+ if op in ["add_node", "instantiate_scene", "duplicate_node"]:
913
+ var parent_variant: Variant = stage.get("parent")
914
+ if not parent_variant is Node:
915
+ return
916
+ var parent: Node = parent_variant
917
+ manager.add_do_method(parent, "add_child", stage_node, true)
918
+ manager.add_do_method(self, "_set_scene_owner_recursive", stage_node, root)
919
+ manager.add_do_reference(stage_node)
920
+ manager.add_undo_method(parent, "remove_child", stage_node)
921
+ elif op == "remove_node":
922
+ var parent_variant: Variant = stage.get("parent")
923
+ if not parent_variant is Node:
924
+ return
925
+ var parent: Node = parent_variant
926
+ manager.add_do_method(parent, "remove_child", stage_node)
927
+ manager.add_undo_method(parent, "add_child", stage_node, true)
928
+ manager.add_undo_method(parent, "move_child", stage_node, _variant_int(stage.get("index", 0)))
929
+ manager.add_undo_property(stage_node, "owner", stage.get("owner"))
930
+ elif op == "rename_node":
931
+ manager.add_do_property(stage_node, "name", stage.get("after"))
932
+ manager.add_undo_property(stage_node, "name", stage.get("before"))
933
+ elif op == "reparent_node":
934
+ var after_parent_variant: Variant = stage.get("after_parent")
935
+ var before_parent_variant: Variant = stage.get("before_parent")
936
+ if not after_parent_variant is Node or not before_parent_variant is Node:
937
+ return
938
+ var after_parent: Node = after_parent_variant
939
+ var before_parent: Node = before_parent_variant
940
+ var keep_global: bool = _variant_bool(stage.get("keep_global", true))
941
+ manager.add_do_method(stage_node, "reparent", after_parent, keep_global)
942
+ manager.add_undo_method(stage_node, "reparent", before_parent, keep_global)
943
+ elif op == "set_property":
944
+ var property_name: StringName = StringName(str(stage.get("property", "")))
945
+ manager.add_do_property(stage_node, property_name, stage.get("after"))
946
+ manager.add_undo_property(stage_node, property_name, stage.get("before"))
947
+
948
+ func _set_scene_owner_recursive(node: Node, scene_owner: Node) -> void:
949
+ node.owner = scene_owner
950
+ for child: Node in node.get_children(true):
951
+ _set_scene_owner_recursive(child, scene_owner)
952
+
953
+ func _transaction_node(root: Node, path: String, staged_nodes: Dictionary) -> Node:
954
+ var normalized: String = _normalize_transaction_path(path)
955
+ if normalized == ".": return root
956
+ if staged_nodes.has(normalized): return staged_nodes[normalized]
957
+ return null
958
+
959
+ func _normalize_transaction_path(path: String) -> String:
960
+ var normalized: String = path.strip_edges().trim_prefix("root/").trim_prefix("./").trim_suffix("/")
961
+ return "." if normalized in ["", ".", "root"] else normalized
962
+
963
+ func _index_transaction_subtree(
964
+ node: Node,
965
+ path: String,
966
+ virtual_nodes: Dictionary,
967
+ virtual_paths: Dictionary,
968
+ staged_node_ids: Dictionary = {},
969
+ mark_staged: bool = false,
970
+ ) -> void:
971
+ var normalized_path: String = _normalize_transaction_path(path)
972
+ virtual_nodes[normalized_path] = node
973
+ virtual_paths[node.get_instance_id()] = normalized_path
974
+ if mark_staged:
975
+ staged_node_ids[node.get_instance_id()] = true
976
+ for child: Node in node.get_children():
977
+ _index_transaction_subtree(child, _joined_node_path(normalized_path, str(child.name)), virtual_nodes, virtual_paths, staged_node_ids, mark_staged)
978
+
979
+ func _remove_transaction_subtree(node: Node, virtual_nodes: Dictionary, virtual_paths: Dictionary) -> void:
980
+ var root_path: String = str(virtual_paths.get(node.get_instance_id(), ""))
981
+ if root_path.is_empty():
982
+ return
983
+ for path_variant: Variant in virtual_nodes.keys():
984
+ var path: String = str(path_variant)
985
+ if path == root_path or path.begins_with(root_path + "/"):
986
+ var removed_node_variant: Variant = virtual_nodes.get(path)
987
+ if removed_node_variant is Node:
988
+ var removed_node: Node = removed_node_variant
989
+ @warning_ignore("return_value_discarded")
990
+ virtual_paths.erase(removed_node.get_instance_id())
991
+ @warning_ignore("return_value_discarded")
992
+ virtual_nodes.erase(path)
993
+
994
+ func _move_transaction_subtree(node: Node, new_path: String, virtual_nodes: Dictionary, virtual_paths: Dictionary) -> void:
995
+ var old_path: String = str(virtual_paths.get(node.get_instance_id(), ""))
996
+ var normalized_new_path: String = _normalize_transaction_path(new_path)
997
+ if old_path.is_empty() or old_path == normalized_new_path:
998
+ return
999
+ var moved_nodes: Array[Dictionary] = []
1000
+ for path_variant: Variant in virtual_nodes.keys():
1001
+ var path: String = str(path_variant)
1002
+ if path == old_path or path.begins_with(old_path + "/"):
1003
+ var moved_node_variant: Variant = virtual_nodes.get(path)
1004
+ if moved_node_variant is Node:
1005
+ var suffix: String = path.trim_prefix(old_path)
1006
+ moved_nodes.append({"node": moved_node_variant, "path": normalized_new_path + suffix})
1007
+ @warning_ignore("return_value_discarded")
1008
+ virtual_nodes.erase(path)
1009
+ for moved: Dictionary in moved_nodes:
1010
+ var moved_node: Node = moved.node
1011
+ var moved_path: String = str(moved.path)
1012
+ virtual_nodes[moved_path] = moved_node
1013
+ virtual_paths[moved_node.get_instance_id()] = moved_path
1014
+
1015
+ func _joined_node_path(parent_path: String, node_name: String) -> String:
1016
+ var normalized: String = _normalize_transaction_path(parent_path)
1017
+ return node_name if normalized in ["", ".", "root"] else normalized.path_join(node_name)
1018
+
1019
+ func _resource_path(path: String) -> String:
1020
+ return path if path.begins_with("res://") else "res://" + path.trim_prefix("/")
1021
+
1022
+ func _ensure_resource_parent_directory(resource_path: String) -> Error:
1023
+ var parent: String = resource_path.get_base_dir()
1024
+ if parent in ["", "res://"]:
1025
+ return OK
1026
+ return DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(parent))
1027
+
1028
+ func _decode_editor_value(value: Variant, current: Variant) -> Dictionary:
1029
+ if value is Dictionary:
1030
+ var typed: Dictionary = value
1031
+ if not typed.has("type"):
1032
+ return _decode_untyped_editor_value(typed, current)
1033
+ var type_name: String = str(typed.get("type", ""))
1034
+ var data: Variant = typed.get("value")
1035
+ var data_array: Array = data if data is Array else []
1036
+ if type_name == "Vector2" and data_array.size() == 2: return {"value": Vector2(_variant_float(data_array[0]), _variant_float(data_array[1]))}
1037
+ if type_name == "Vector2i" and data_array.size() == 2: return {"value": Vector2i(_variant_int(data_array[0]), _variant_int(data_array[1]))}
1038
+ if type_name == "Vector3" and data_array.size() == 3: return {"value": Vector3(_variant_float(data_array[0]), _variant_float(data_array[1]), _variant_float(data_array[2]))}
1039
+ if type_name == "Vector3i" and data_array.size() == 3: return {"value": Vector3i(_variant_int(data_array[0]), _variant_int(data_array[1]), _variant_int(data_array[2]))}
1040
+ if type_name == "Color": return {"value": Color(str(data))}
1041
+ if type_name == "NodePath": return {"value": NodePath(str(data))}
1042
+ if type_name == "StringName": return {"value": StringName(str(data))}
1043
+ if type_name == "Resource":
1044
+ var resource: Resource = ResourceLoader.load(_resource_path(str(data)))
1045
+ return {"error": "resource_not_found"} if resource == null else {"value": resource}
1046
+ return {"error": "unsupported_typed_value"}
1047
+ match typeof(current):
1048
+ TYPE_INT: return {"value": _variant_int(value)}
1049
+ TYPE_FLOAT: return {"value": _variant_float(value)}
1050
+ TYPE_BOOL: return {"value": _variant_bool(value)}
1051
+ TYPE_STRING: return {"value": str(value)}
1052
+ TYPE_STRING_NAME: return {"value": StringName(str(value))}
1053
+ TYPE_NODE_PATH: return {"value": NodePath(str(value))}
1054
+ TYPE_OBJECT:
1055
+ if current is Resource and value is String:
1056
+ var referenced: Resource = ResourceLoader.load(_resource_path(str(value)))
1057
+ return {"error": "resource_not_found"} if referenced == null else {"value": referenced}
1058
+ return {"value": value}
1059
+
1060
+ func _decode_untyped_editor_value(value: Dictionary, current: Variant) -> Dictionary:
1061
+ match typeof(current):
1062
+ TYPE_VECTOR2:
1063
+ if value.has("x") and value.has("y"): return {"value": Vector2(_variant_float(value.get("x")), _variant_float(value.get("y")))}
1064
+ TYPE_VECTOR2I:
1065
+ if value.has("x") and value.has("y"): return {"value": Vector2i(_variant_int(value.get("x")), _variant_int(value.get("y")))}
1066
+ TYPE_VECTOR3:
1067
+ if value.has("x") and value.has("y") and value.has("z"): return {"value": Vector3(_variant_float(value.get("x")), _variant_float(value.get("y")), _variant_float(value.get("z")))}
1068
+ TYPE_VECTOR3I:
1069
+ if value.has("x") and value.has("y") and value.has("z"): return {"value": Vector3i(_variant_int(value.get("x")), _variant_int(value.get("y")), _variant_int(value.get("z")))}
1070
+ TYPE_COLOR:
1071
+ if value.has("r") and value.has("g") and value.has("b"): return {"value": Color(_variant_float(value.get("r")), _variant_float(value.get("g")), _variant_float(value.get("b")), _variant_float(value.get("a", 1.0)))}
1072
+ return {"value": value}
1073
+
1074
+ func _independent_scene_readback(scene_path: String) -> Dictionary:
1075
+ var packed: Resource = ResourceLoader.load(scene_path, "PackedScene", ResourceLoader.CACHE_MODE_IGNORE)
1076
+ if not packed is PackedScene:
1077
+ return {"error": "packed_scene_reload_failed"}
1078
+ var packed_scene: PackedScene = packed
1079
+ var instance: Node = packed_scene.instantiate()
1080
+ if instance == null:
1081
+ return {"error": "packed_scene_instantiate_failed"}
1082
+ var node_count: int = _count_scene_nodes(instance)
1083
+ var hierarchy: Array[String] = []
1084
+ _collect_scene_paths(instance, instance, hierarchy, 256)
1085
+ var result: Dictionary = {
1086
+ "scene_path": scene_path,
1087
+ "root_name": str(instance.name),
1088
+ "root_type": instance.get_class(),
1089
+ "node_count": node_count,
1090
+ "hierarchy": hierarchy,
1091
+ "independently_reopened": true,
1092
+ }
1093
+ instance.free()
1094
+ return result
1095
+
1096
+ func _count_scene_nodes(node: Node) -> int:
1097
+ var count: int = 1
1098
+ for child: Node in node.get_children(): count += _count_scene_nodes(child)
1099
+ return count
1100
+
1101
+ func _collect_scene_paths(root: Node, node: Node, paths: Array[String], limit: int) -> void:
1102
+ if paths.size() >= limit: return
1103
+ paths.append("." if node == root else str(root.get_path_to(node)))
1104
+ for child: Node in node.get_children(): _collect_scene_paths(root, child, paths, limit)
1105
+
405
1106
  func _send(response: Dictionary) -> void:
406
1107
  if _peer == null:
407
1108
  return
@@ -415,3 +1116,117 @@ func _read_port() -> int:
415
1116
  var value: int = int(configured)
416
1117
  if value > 0 and value < 65536: return value
417
1118
  return _port
1119
+
1120
+ func _on_scene_changed(scene_root: Node) -> void:
1121
+ if scene_root != null and not scene_root.scene_file_path.is_empty():
1122
+ _saved_history_versions[scene_root.scene_file_path] = _history_version(scene_root)
1123
+
1124
+ func _on_scene_saved(filepath: String) -> void:
1125
+ var root: Node = EditorInterface.get_edited_scene_root()
1126
+ if root != null and root.scene_file_path == filepath:
1127
+ _saved_history_versions[filepath] = _history_version(root)
1128
+
1129
+ func _history_version(root: Node) -> int:
1130
+ var manager: EditorUndoRedoManager = EditorInterface.get_editor_undo_redo()
1131
+ if manager == null:
1132
+ return 0
1133
+ var history_id: int = manager.get_object_history_id(root)
1134
+ var history: UndoRedo = manager.get_history_undo_redo(history_id)
1135
+ return 0 if history == null else history.get_version()
1136
+
1137
+ func _scene_has_unsaved_changes(root: Node) -> bool:
1138
+ var path: String = root.scene_file_path
1139
+ if path.is_empty():
1140
+ return true
1141
+ var current_version: int = _history_version(root)
1142
+ if not _saved_history_versions.has(path):
1143
+ _saved_history_versions[path] = current_version
1144
+ return false
1145
+ return _variant_int(_saved_history_versions[path]) != current_version
1146
+
1147
+ func _variant_int(value: Variant) -> int:
1148
+ if value is int:
1149
+ return value
1150
+ if value is float:
1151
+ var float_value: float = value
1152
+ return roundi(float_value)
1153
+ if value is bool:
1154
+ return 1 if value else 0
1155
+ return 0
1156
+
1157
+ func _variant_float(value: Variant) -> float:
1158
+ if value is float:
1159
+ return value
1160
+ if value is int:
1161
+ var int_value: int = value
1162
+ return float(int_value)
1163
+ if value is bool:
1164
+ return 1.0 if value else 0.0
1165
+ return 0.0
1166
+
1167
+ func _variant_bool(value: Variant) -> bool:
1168
+ if value is bool:
1169
+ return value
1170
+ if value is int:
1171
+ return value != 0
1172
+ if value is float:
1173
+ var float_value: float = value
1174
+ return not is_zero_approx(float_value)
1175
+ return false
1176
+
1177
+ func _creation_fallback(created: bool, message: String) -> Variant:
1178
+ if created:
1179
+ return message
1180
+ return null
1181
+
1182
+ func _write_discovery_record() -> Error:
1183
+ var directory: String = _project_path.path_join(SESSION_DIRECTORY)
1184
+ var directory_error: Error = DirAccess.make_dir_recursive_absolute(directory)
1185
+ if directory_error != OK:
1186
+ return directory_error
1187
+ var record: Dictionary = {
1188
+ "project_path": _project_path,
1189
+ "editor_pid": _editor_pid,
1190
+ "editor_start_identity": _editor_start_identity,
1191
+ "port": _port,
1192
+ "token": _secret,
1193
+ "protocol_version": PROTOCOL_VERSION,
1194
+ "addon_version": ADDON_VERSION,
1195
+ "godot_version": str(Engine.get_version_info().get("string", "unknown")),
1196
+ "created_at": str(Time.get_unix_time_from_system()),
1197
+ }
1198
+ var temporary_path: String = _session_path + ".tmp-%d" % _editor_pid
1199
+ var file: FileAccess = FileAccess.open(temporary_path, FileAccess.WRITE)
1200
+ if file == null:
1201
+ return FileAccess.get_open_error()
1202
+ @warning_ignore("return_value_discarded")
1203
+ file.store_string(JSON.stringify(record) + "\n")
1204
+ file.flush()
1205
+ file.close()
1206
+ var permission_error: Error = FileAccess.set_unix_permissions(temporary_path, 384)
1207
+ if permission_error != OK and OS.get_name() not in ["Windows", "Web"]:
1208
+ @warning_ignore("return_value_discarded")
1209
+ DirAccess.remove_absolute(temporary_path)
1210
+ return permission_error
1211
+ if FileAccess.file_exists(_session_path):
1212
+ @warning_ignore("return_value_discarded")
1213
+ DirAccess.remove_absolute(_session_path)
1214
+ var rename_error: Error = DirAccess.rename_absolute(temporary_path, _session_path)
1215
+ if rename_error != OK:
1216
+ @warning_ignore("return_value_discarded")
1217
+ DirAccess.remove_absolute(temporary_path)
1218
+ return rename_error
1219
+
1220
+ func _remove_owned_discovery_record() -> void:
1221
+ if _session_path.is_empty() or not FileAccess.file_exists(_session_path):
1222
+ return
1223
+ var file: FileAccess = FileAccess.open(_session_path, FileAccess.READ)
1224
+ if file == null:
1225
+ return
1226
+ var parsed: Variant = JSON.parse_string(file.get_as_text())
1227
+ file.close()
1228
+ var parsed_record: Dictionary = parsed if parsed is Dictionary else {}
1229
+ if not parsed_record.is_empty() and str(parsed_record.get("editor_start_identity", "")) == _editor_start_identity:
1230
+ var remove_error: Error = DirAccess.remove_absolute(_session_path)
1231
+ if remove_error != OK:
1232
+ push_warning("Godot Agent Loop could not remove editor discovery record: %s" % error_string(remove_error))