gdharness 1.3.67 → 1.3.69

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.
@@ -15,6 +15,7 @@ const GdhPropertyValues = preload("../property_values.gd")
15
15
  const GdhSame = preload("../same_values.gd")
16
16
  const GdhSceneFile = preload("../scene_file.gd")
17
17
  const GdhSceneSharing = preload("scene_sharing.gd")
18
+ const GdhScriptPaths = preload("script_paths.gd")
18
19
  const GdhSerialisation = preload("../serialisation.gd")
19
20
  const GdhTilesetTools = preload("tileset_tools.gd")
20
21
 
@@ -126,10 +127,11 @@ func _inherited(args: Dictionary) -> Dictionary:
126
127
  )
127
128
 
128
129
 
129
- ## The res:// path a create in [param args] writes, given .tscn when it has no extension, or "".
130
- func _new_scene_path(args: Dictionary) -> String:
130
+ ## The res:// path [param args] give under [param argument] for a new scene, given .tscn when it
131
+ ## has no extension, or "".
132
+ func _new_scene_path(args: Dictionary, argument: String = "scenePath") -> String:
131
133
  var scene_path: String = GdhSceneFile.res_path(
132
- str(args.get("projectPath", "")), str(args.get("scenePath", ""))
134
+ str(args.get("projectPath", "")), str(args.get(argument, ""))
133
135
  )
134
136
  if scene_path == "res://":
135
137
  return ""
@@ -261,9 +263,15 @@ func add_node(args: Dictionary) -> Dictionary:
261
263
  var parent_node_path: String = str(args.get("parentNodePath", "."))
262
264
  var properties: Dictionary = _parse_properties_arg(args.get("properties", {}))
263
265
  var adding: Array = GdhGroups.asked(args, "addGroups")
266
+ var placeholder: bool = GdhRead.as_bool(args.get("placeholder", false))
264
267
 
265
268
  if node_name.is_empty():
266
269
  return { "ok": false, "error": "Missing nodeName" }
270
+ if placeholder and instance_path.is_empty():
271
+ return {
272
+ "ok": false,
273
+ "error": "placeholder marks an instance of a scene, so add takes it with instancePath.",
274
+ }
267
275
  var class_script: Script = null
268
276
  if not node_type.is_empty() and not ClassDB.class_exists(node_type):
269
277
  var declared: Dictionary = GdhSceneFile.script_class(node_type, "Node")
@@ -301,18 +309,28 @@ func add_node(args: Dictionary) -> Dictionary:
301
309
  if not unplaceable.is_empty():
302
310
  return scene.refuse(unplaceable)
303
311
 
304
- # In the tree before its properties, so a property holding a node can find it by path.
312
+ # In the tree before its properties, so a property holding a node can find it by path. The
313
+ # script goes on once the node is in place, as an add and then a set of the script would put
314
+ # it. What the parent gives the node, such as a Container's layout mode, is then what the node
315
+ # held before its script, and only what the script's _init changed is put back.
305
316
  var new_node: Node = GdhPlacing.made(packed, node_type)
306
- if class_script != null:
307
- scene.asked(new_node, ["script"])
308
- new_node.set_script(class_script)
309
317
  new_node.name = node_name
310
318
  parent.add_child(new_node)
311
319
  GdhPlacing.place(new_node, place)
312
320
  GdhPlacing.own(new_node, root)
321
+ if placeholder:
322
+ new_node.set_scene_instance_load_placeholder(true)
323
+ if class_script != null:
324
+ scene.asked(new_node, ["script"])
325
+ new_node.set_script(class_script)
326
+ # The script's _init can name the node, and the call's name is the one kept.
327
+ new_node.name = node_name
313
328
  var unsettled: String = scene.settle()
314
329
  if not unsettled.is_empty():
315
330
  return scene.refuse("Nothing was written: " + unsettled)
331
+ if GdhPlacing.cornered(new_node, packed):
332
+ scene.asked(new_node, ["layout_mode"])
333
+ new_node.set("layout_mode", GdhPlacing.ANCHORS_MODE)
316
334
  scene.asked(new_node, properties.keys())
317
335
  var unwritten: String = _properties.write_all(new_node, properties)
318
336
  scene.asked(new_node, _properties.moved_by_engine.keys())
@@ -347,6 +365,8 @@ func add_node(args: Dictionary) -> Dictionary:
347
365
  )
348
366
  if unkept.is_empty() and saved.get_node_or_null(new_path) != null:
349
367
  unkept = GdhGroups.unkept(saved.get_node(new_path), adding, [])
368
+ if unkept.is_empty() and placeholder:
369
+ unkept = _placeholder_unkept(saved, new_path, true)
350
370
  return unkept
351
371
  )
352
372
  if not written.is_empty():
@@ -356,9 +376,25 @@ func add_node(args: Dictionary) -> Dictionary:
356
376
  _note_loads_as(answer, scene, new_path)
357
377
  if packed != null:
358
378
  answer["instanceOf"] = instance_path
379
+ answer["placeholder"] = placeholder
359
380
  return answer
360
381
 
361
382
 
383
+ ## Why the node at [param at] in [param saved], the scene as written, would not load with
384
+ ## placeholder [param wanted], or "" when it would.
385
+ static func _placeholder_unkept(saved: Node, at: String, wanted: bool) -> String:
386
+ var node: Node = saved.get_node_or_null(at)
387
+ if node == null or _placeholder_of(node) == wanted:
388
+ return ""
389
+ return "%s would load with placeholder %s." % [at, not wanted]
390
+
391
+
392
+ ## Whether [param node] is an instance saved as a placeholder. An engine with no editor opens one as
393
+ ## an InstancePlaceholder even in an edit state, and an editor opens the instance itself, marked.
394
+ static func _placeholder_of(node: Node) -> bool:
395
+ return node is InstancePlaceholder or node.get_scene_instance_load_placeholder()
396
+
397
+
362
398
  func delete_node(args: Dictionary) -> Dictionary:
363
399
  var project_path: String = str(args.get("projectPath", ""))
364
400
  var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
@@ -658,6 +694,449 @@ func reparent_node(args: Dictionary) -> Dictionary:
658
694
  return answer
659
695
 
660
696
 
697
+ ## Saves the branch at nodePath as a scene of its own at newScenePath and puts an instance of that
698
+ ## scene where the branch was, as the editor's Save Branch as Scene does. The new file holds the
699
+ ## branch's nodes, values, resources, groups and the connections among them, with its root's
700
+ ## position reset as the editor resets it. The instance keeps the branch's name, place, position,
701
+ ## rotation and scale. The rest of the scene keeps what it named in the branch: the connections to
702
+ ## its nodes, the properties holding them and the %unique paths, which no longer reach a node
703
+ ## unique only in the new scene.
704
+ func save_branch(args: Dictionary) -> Dictionary:
705
+ var project_path: String = str(args.get("projectPath", ""))
706
+ var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
707
+ var node_path: String = str(args.get("nodePath", ""))
708
+ var branch_path: String = _new_scene_path(args, "newScenePath")
709
+ if node_path.is_empty() or node_path == ".":
710
+ return {
711
+ "ok": false,
712
+ "error":
713
+ "The root is the whole scene, which scene_create save_as copies to a new file.",
714
+ }
715
+ if branch_path.is_empty():
716
+ return { "ok": false, "error": "Missing newScenePath" }
717
+ if branch_path == scene_path:
718
+ return {
719
+ "ok": false, "error": "newScenePath is %s, the scene the branch is in." % scene_path,
720
+ }
721
+ if FileAccess.file_exists(branch_path):
722
+ return {
723
+ "ok": false,
724
+ "error": "%s already exists, and save_branch writes a new scene only." % branch_path,
725
+ }
726
+
727
+ var opened: Array = _open(scene_path, true)
728
+ var scene: GdhSceneFile = opened[0]
729
+ var refused: Dictionary = opened[1]
730
+ if not refused.is_empty():
731
+ return refused
732
+ var root: Node = scene.root
733
+ var top: Node = GdhSceneFile.node_at(root, node_path)
734
+ if top == null:
735
+ return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
736
+ var fixed: String = scene.fixed(top)
737
+ if not fixed.is_empty():
738
+ return scene.refuse(fixed)
739
+ if not top.scene_file_path.is_empty():
740
+ return scene.refuse(
741
+ "%s is an instance of %s, its scene already." % [node_path, top.scene_file_path]
742
+ )
743
+
744
+ var at: String = scene.path_of(top)
745
+ var top_name: StringName = top.name
746
+ var parent: Node = top.get_parent()
747
+ var index: int = top.get_index()
748
+ var unique: bool = top.unique_name_in_owner
749
+ var placed_at: Dictionary = {}
750
+ if top is Node2D or top is Node3D:
751
+ for property: String in ["position", "rotation", "scale"]:
752
+ placed_at[property] = top.get(property)
753
+ var outside: Array[Node] = [root]
754
+ var inside: Array[Node] = [top]
755
+ for node: Node in root.find_children("*", "", true, false):
756
+ if node.owner == root and node != top:
757
+ if top.is_ancestor_of(node):
758
+ inside.append(node)
759
+ else:
760
+ outside.append(node)
761
+
762
+ var answer: Dictionary = { "ok": true, "nodePath": at, "newScenePath": branch_path }
763
+ var enclosed: Dictionary = {}
764
+ for node: Node in inside:
765
+ if node != top and node.unique_name_in_owner:
766
+ enclosed[str(node.name)] = scene.path_of(node)
767
+ var held: Array[Array] = _holding_the_branch(outside, top)
768
+ var crossing: Array[Array] = _crossing(outside, inside, top)
769
+ _named_outside(scene, inside, top, answer)
770
+ var scripts_out: Array[Dictionary] = _scripts_naming_outside(inside, top, branch_path)
771
+
772
+ var split: Array = scene.split_off(top, branch_path)
773
+ var branch: GdhSceneFile = split[0]
774
+ var crossing_unmade: Array[Dictionary] = split[1]
775
+ var editable: Array[Node] = []
776
+ for node: Node in inside:
777
+ if node != top and not node.scene_file_path.is_empty() and root.is_editable_instance(node):
778
+ editable.append(node)
779
+ var below: Array[String] = []
780
+ for node: Node in inside:
781
+ below.append(str(top.get_path_to(node)))
782
+ branch.asked(top, ["unique_name_in_owner"] + placed_at.keys())
783
+ parent.remove_child(top)
784
+ top.unique_name_in_owner = false
785
+ top.owner = null
786
+ for node: Node in inside:
787
+ if node != top:
788
+ node.owner = top
789
+ for node: Node in editable:
790
+ top.set_editable_instance(node, true)
791
+ if top is Node2D:
792
+ (top as Node2D).position = Vector2.ZERO
793
+ elif top is Node3D:
794
+ (top as Node3D).position = Vector3.ZERO
795
+ var written: Dictionary = branch.write(
796
+ func(saved: Node) -> String:
797
+ for one: String in below:
798
+ if saved.get_node_or_null(one) == null:
799
+ return "%s would not be in %s when it loads." % [one, branch_path]
800
+ return ""
801
+ )
802
+ if not written.is_empty():
803
+ scene.close()
804
+ return written
805
+
806
+ var packed: PackedScene = ResourceLoader.load(branch_path, "PackedScene") as PackedScene
807
+ var instance: Node = (
808
+ packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) if packed != null else null
809
+ )
810
+ if instance == null:
811
+ return _unwritten(scene, branch_path, "%s did not instance once written." % branch_path)
812
+ instance.name = top_name
813
+ parent.add_child(instance)
814
+ parent.move_child(instance, index)
815
+ instance.owner = root
816
+ # Before the scene is next packed, which reads the path to every node a property holds.
817
+ for one: Array in held:
818
+ var holder: Node = one[0]
819
+ var holding: String = one[1]
820
+ scene.asked(holder, [holding])
821
+ holder.set(holding, _unmarked(one[2], instance))
822
+ var unsettled: String = scene.settle()
823
+ if not unsettled.is_empty():
824
+ return _unwritten(scene, branch_path, unsettled)
825
+ scene.asked(instance, ["unique_name_in_owner"] + placed_at.keys())
826
+ instance.unique_name_in_owner = unique
827
+ for property: String in placed_at:
828
+ instance.set(property, placed_at[property])
829
+ scene.rejoin_unmade(crossing_unmade, instance)
830
+ var lost: Array[Dictionary] = _rejoined(scene, crossing, instance)
831
+ if not lost.is_empty():
832
+ answer["connectionsLost"] = lost
833
+ var carried: GdhPaths = GdhPaths.new()
834
+ var _rewritten: int = carried.carry_enclosed(scene, enclosed)
835
+
836
+ written = scene.write(
837
+ func(saved: Node) -> String:
838
+ var copy: Node = saved.get_node_or_null(at)
839
+ if copy == null or copy.scene_file_path != branch_path:
840
+ return "%s would not load as an instance of %s." % [at, branch_path]
841
+ if copy.get_index() != index:
842
+ return "%s would load at index %d, not %d." % [at, copy.get_index(), index]
843
+ return ""
844
+ )
845
+ if not written.is_empty():
846
+ carried.put_back(scene_path)
847
+ return _unwritten(null, branch_path, str(written.get("error", "")))
848
+ answer["index"] = index
849
+ answer.merge(carried.answer())
850
+ answer.merge(GdhKin.enclose(scene_path, at))
851
+ if not scripts_out.is_empty():
852
+ var naming: Array = answer.get_or_add("scriptsNaming", [])
853
+ naming.append_array(scripts_out)
854
+ return answer
855
+
856
+
857
+ ## Removes the scene [method save_branch] wrote at [param branch_path] and answers [param why] as
858
+ ## its refusal. [param scene] is closed when it is still open.
859
+ static func _unwritten(scene: GdhSceneFile, branch_path: String, why: String) -> Dictionary:
860
+ if scene != null:
861
+ scene.close()
862
+ var _removed: Error = DirAccess.remove_absolute(branch_path)
863
+ if Engine.is_editor_hint():
864
+ GdhSceneFile.scan_later()
865
+ return { "ok": false, "error": why }
866
+
867
+
868
+ ## Each property of the nodes [param outside] the branch at [param top] that holds a node of it, as
869
+ ## [holder, property, the value with each such node marked by its path from [param top]], for
870
+ ## [method _unmarked] to give the instance's node at that path.
871
+ static func _holding_the_branch(outside: Array[Node], top: Node) -> Array[Array]:
872
+ var held: Array[Array] = []
873
+ for node: Node in outside:
874
+ for property: Dictionary in node.get_property_list():
875
+ if not GdhRead.as_int(property.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
876
+ continue
877
+ var called: String = str(property["name"])
878
+ var marked: Array = _marked(node.get(called), top)
879
+ if marked[0]:
880
+ held.append([node, called, marked[1]])
881
+ return held
882
+
883
+
884
+ ## [param value] with each node of the branch at [param top] in it as a [GdhBranchMark], as
885
+ ## [whether it held one, the value].
886
+ static func _marked(value: Variant, top: Node) -> Array:
887
+ if typeof(value) == TYPE_OBJECT and is_instance_valid(value) and value is Node:
888
+ var node: Node = value
889
+ if node == top or top.is_ancestor_of(node):
890
+ return [true, GdhBranchMark.new(top.get_path_to(node))]
891
+ elif value is Array:
892
+ var items: Array = value
893
+ var changed: bool = false
894
+ var copy: Array = items.duplicate()
895
+ for at: int in items.size():
896
+ var one: Array = _marked(items[at], top)
897
+ if one[0]:
898
+ copy[at] = one[1]
899
+ changed = true
900
+ return [changed, copy if changed else value]
901
+ elif value is Dictionary:
902
+ var pairs: Dictionary = value
903
+ var changed: bool = false
904
+ var copy: Dictionary = pairs.duplicate()
905
+ for key: Variant in pairs:
906
+ var one: Array = _marked(pairs[key], top)
907
+ if one[0]:
908
+ copy[key] = one[1]
909
+ changed = true
910
+ return [changed, copy if changed else value]
911
+ return [false, value]
912
+
913
+
914
+ ## [param value] with each [GdhBranchMark] in it as the node at its path below [param instance].
915
+ static func _unmarked(value: Variant, instance: Node) -> Variant:
916
+ if value is GdhBranchMark:
917
+ var mark: GdhBranchMark = value
918
+ return instance.get_node_or_null(mark.below)
919
+ if value is Array:
920
+ var items: Array = value
921
+ return items.map(func(item: Variant) -> Variant: return _unmarked(item, instance))
922
+ if value is Dictionary:
923
+ var pairs: Dictionary = value
924
+ var copy: Dictionary = pairs.duplicate()
925
+ for key: Variant in pairs:
926
+ copy[key] = _unmarked(pairs[key], instance)
927
+ return copy
928
+ return value
929
+
930
+
931
+ ## Each node path and node the nodes [param inside] the branch at [param top] hold that reaches a
932
+ ## node outside it, answered under namesOutside as {nodePath, property, path, names} with nodePath
933
+ ## the holder's path in the new scene and names the node's path here. A path is kept, and names
934
+ ## that node only where the new scene is instanced as here. A node is cleared, with cleared: true,
935
+ ## since the new scene's file can only save it as a path within itself.
936
+ func _named_outside(
937
+ scene: GdhSceneFile, inside: Array[Node], top: Node, answer: Dictionary
938
+ ) -> void:
939
+ var named: Array[Dictionary] = []
940
+ for node: Node in inside:
941
+ for property: Dictionary in node.get_property_list():
942
+ if not GdhRead.as_int(property.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
943
+ continue
944
+ var called: String = str(property["name"])
945
+ if GdhHeadlessCheck.at_default(node, called):
946
+ continue
947
+ var record: Dictionary = {
948
+ "nodePath": str(top.get_path_to(node)), "property": called,
949
+ }
950
+ var kept: Array = _outside_of(node.get(called), node, top, scene, record, named)
951
+ if kept[0]:
952
+ scene.asked(node, [called])
953
+ node.set(called, kept[1])
954
+ if not named.is_empty():
955
+ answer["namesOutside"] = named
956
+
957
+
958
+ ## [param value], held by [param holder] in the branch at [param top], with each node outside the
959
+ ## branch in it cleared, each such node and each path reaching one recorded from [param record] in
960
+ ## [param named], as [whether a node was cleared, the value].
961
+ static func _outside_of(
962
+ value: Variant,
963
+ holder: Node,
964
+ top: Node,
965
+ scene: GdhSceneFile,
966
+ record: Dictionary,
967
+ named: Array[Dictionary]
968
+ ) -> Array:
969
+ if value is NodePath:
970
+ var path: NodePath = value
971
+ var target: Node = (
972
+ holder.get_node_or_null(NodePath(path.get_concatenated_names()))
973
+ if path.get_name_count() > 0
974
+ else null
975
+ )
976
+ if target != null and target != top and not top.is_ancestor_of(target):
977
+ var entry: Dictionary = record.duplicate()
978
+ entry["path"] = str(path)
979
+ entry["names"] = scene.path_of(target)
980
+ named.append(entry)
981
+ return [false, value]
982
+ if typeof(value) == TYPE_OBJECT and is_instance_valid(value) and value is Node:
983
+ var node: Node = value
984
+ if node == top or top.is_ancestor_of(node):
985
+ return [false, value]
986
+ var entry: Dictionary = record.duplicate()
987
+ entry["names"] = scene.path_of(node)
988
+ entry["cleared"] = true
989
+ named.append(entry)
990
+ return [true, null]
991
+ if value is Array:
992
+ var items: Array = value
993
+ var changed: bool = false
994
+ var copy: Array = items.duplicate()
995
+ for at: int in items.size():
996
+ var one: Array = _outside_of(items[at], holder, top, scene, record, named)
997
+ if one[0]:
998
+ copy[at] = one[1]
999
+ changed = true
1000
+ return [changed, copy if changed else value]
1001
+ if value is Dictionary:
1002
+ var pairs: Dictionary = value
1003
+ var changed: bool = false
1004
+ var copy: Dictionary = pairs.duplicate()
1005
+ for key: Variant in pairs:
1006
+ var one: Array = _outside_of(pairs[key], holder, top, scene, record, named)
1007
+ if one[0]:
1008
+ copy[key] = one[1]
1009
+ changed = true
1010
+ return [changed, copy if changed else value]
1011
+ return [false, value]
1012
+
1013
+
1014
+ ## The persistent connections between a node [param outside] the branch at [param top] and one
1015
+ ## [param inside] it, each taken off its source and answered as [source, signal, callable, flags,
1016
+ ## the source's path from [param top] or "", the target's path from [param top] or ""]. The source
1017
+ ## is given only when it is outside, since a node of the branch is freed with it.
1018
+ static func _crossing(outside: Array[Node], inside: Array[Node], top: Node) -> Array[Array]:
1019
+ var crossing: Array[Array] = []
1020
+ for from_inside: bool in [false, true]:
1021
+ var sources: Array[Node] = inside if from_inside else outside
1022
+ for source: Node in sources:
1023
+ for info: Dictionary in source.get_signal_list():
1024
+ var signal_name: String = str(info.get("name", ""))
1025
+ for conn: Dictionary in source.get_signal_connection_list(signal_name):
1026
+ var flags: int = GdhRead.as_int(conn.get("flags", 0))
1027
+ var callable: Callable = conn.get("callable", Callable())
1028
+ var target: Node = callable.get_object() as Node
1029
+ if not flags & Object.CONNECT_PERSIST or target == null:
1030
+ continue
1031
+ if (target == top or top.is_ancestor_of(target)) == from_inside:
1032
+ continue
1033
+ crossing.append(
1034
+ [
1035
+ null if from_inside else source,
1036
+ signal_name,
1037
+ callable,
1038
+ flags,
1039
+ str(top.get_path_to(source)) if from_inside else "",
1040
+ "" if from_inside else str(top.get_path_to(target)),
1041
+ ]
1042
+ )
1043
+ source.disconnect(signal_name, callable)
1044
+ return crossing
1045
+
1046
+
1047
+ ## Makes each of [param crossing], from [method _crossing], again between the node outside and the
1048
+ ## node at its path below [param instance]. Answers each that the scene's file would not hold, as
1049
+ ## {sourceNodePath, signalName, targetNodePath, methodName}: one from a node inside an instance is
1050
+ ## saved only once the instance's children are editable.
1051
+ func _rejoined(scene: GdhSceneFile, crossing: Array[Array], instance: Node) -> Array[Dictionary]:
1052
+ var made: Array[Array] = []
1053
+ for one: Array in crossing:
1054
+ var callable: Callable = one[2]
1055
+ var source: Node = one[0]
1056
+ if not str(one[4]).is_empty():
1057
+ source = instance.get_node_or_null(NodePath(str(one[4])))
1058
+ # The node of the branch a callable aims at has been freed with the branch.
1059
+ var target: Object = (
1060
+ instance.get_node_or_null(NodePath(str(one[5])))
1061
+ if not str(one[5]).is_empty()
1062
+ else callable.get_object()
1063
+ )
1064
+ if source == null or target == null:
1065
+ continue
1066
+ var aimed: Callable = GdhPlacing.aimed(callable, target)
1067
+ if source.connect(str(one[1]), aimed, GdhRead.as_int(one[3])) == OK:
1068
+ made.append([source, str(one[1]), aimed, target])
1069
+ var lost: Array[Dictionary] = []
1070
+ if made.is_empty():
1071
+ return lost
1072
+ var probe: PackedScene = PackedScene.new()
1073
+ if probe.pack(scene.root) != OK:
1074
+ return lost
1075
+ var state: SceneState = probe.get_state()
1076
+ var saved: Dictionary = {}
1077
+ for at: int in state.get_connection_count():
1078
+ var key: String = "%s|%s|%s|%s" % [
1079
+ str(state.get_connection_source(at)).trim_prefix("./"),
1080
+ state.get_connection_signal(at),
1081
+ str(state.get_connection_target(at)).trim_prefix("./"),
1082
+ state.get_connection_method(at),
1083
+ ]
1084
+ saved[key] = true
1085
+ for one: Array in made:
1086
+ var source: Node = one[0]
1087
+ var aimed: Callable = one[2]
1088
+ var target: Node = one[3]
1089
+ var held: Dictionary = {
1090
+ "sourceNodePath": scene.path_of(source),
1091
+ "signalName": one[1],
1092
+ "targetNodePath": scene.path_of(target),
1093
+ "methodName": str(aimed.get_method()),
1094
+ }
1095
+ var key: String = "%s|%s|%s|%s" % held.values()
1096
+ if not saved.has(key):
1097
+ source.disconnect(str(one[1]), aimed)
1098
+ lost.append(held)
1099
+ return lost
1100
+
1101
+
1102
+ ## Each line of a script on a node [param inside] the branch at [param top] that names a node
1103
+ ## outside it through a %unique node, which is not unique in the new scene at [param branch_path],
1104
+ ## as scriptsNaming gives it with no to: {scenePath, nodePath, script, line, from}, nodePath the
1105
+ ## node's path in the new scene.
1106
+ static func _scripts_naming_outside(
1107
+ inside: Array[Node], top: Node, branch_path: String
1108
+ ) -> Array[Dictionary]:
1109
+ var found: Array[Dictionary] = []
1110
+ var seen: Dictionary = {}
1111
+ for node: Node in inside:
1112
+ var script: Script = node.get_script()
1113
+ while script is GDScript:
1114
+ var file: String = script.resource_path
1115
+ var source: String = (script as GDScript).source_code
1116
+ if source.is_empty() and FileAccess.file_exists(file):
1117
+ source = FileAccess.get_file_as_string(file)
1118
+ for named: Dictionary in GdhScriptPaths.named(source):
1119
+ var path: String = named["path"]
1120
+ if not path.begins_with("%"):
1121
+ continue
1122
+ var through: Node = node.get_node_or_null(NodePath(path.get_slice("/", 0)))
1123
+ if through == null or through == top or top.is_ancestor_of(through):
1124
+ continue
1125
+ var record: Dictionary = {
1126
+ "scenePath": branch_path,
1127
+ "nodePath": str(top.get_path_to(node)),
1128
+ "script": file,
1129
+ "line": named["line"],
1130
+ "from": path,
1131
+ }
1132
+ var key: String = "%s:%d:%s:%s" % [file, named["line"], path, record["nodePath"]]
1133
+ if not seen.has(key):
1134
+ seen[key] = true
1135
+ found.append(record)
1136
+ script = script.get_base_script()
1137
+ return found
1138
+
1139
+
661
1140
  func set_node_properties(args: Dictionary) -> Dictionary:
662
1141
  var project_path: String = str(args.get("projectPath", ""))
663
1142
  var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
@@ -666,10 +1145,18 @@ func set_node_properties(args: Dictionary) -> Dictionary:
666
1145
  var editable: Variant = args.get("editableChildren")
667
1146
  var adding: Array = GdhGroups.asked(args, "addGroups")
668
1147
  var removing: Array = GdhGroups.asked(args, "removeGroups")
669
- if properties.is_empty() and editable == null and adding.is_empty() and removing.is_empty():
1148
+ var placeholder: Variant = args.get("placeholder")
1149
+ if (
1150
+ properties.is_empty()
1151
+ and editable == null
1152
+ and placeholder == null
1153
+ and adding.is_empty()
1154
+ and removing.is_empty()
1155
+ ):
670
1156
  return {
671
1157
  "ok": false,
672
- "error": "set takes properties, editableChildren, addGroups or removeGroups.",
1158
+ "error":
1159
+ "set takes properties, editableChildren, placeholder, addGroups or removeGroups.",
673
1160
  }
674
1161
 
675
1162
  var opened: Array = _open(scene_path, true)
@@ -701,6 +1188,16 @@ func set_node_properties(args: Dictionary) -> Dictionary:
701
1188
  return scene.refuse(not_an_instance)
702
1189
  if not GdhRead.as_bool(editable):
703
1190
  dropped = scene.held_under(scene.path_of(node))
1191
+ if placeholder != null:
1192
+ # The editor offers Load as Placeholder only on an instance this scene places itself.
1193
+ if node == scene.root or node.owner != scene.root or node.scene_file_path.is_empty():
1194
+ return scene.refuse(
1195
+ (
1196
+ "placeholder is a setting of an instance this scene places, and %s is not one."
1197
+ % scene.path_of(node)
1198
+ )
1199
+ )
1200
+ node.set_scene_instance_load_placeholder(GdhRead.as_bool(placeholder))
704
1201
 
705
1202
  var old_path: String = scene.path_of(node)
706
1203
  scene.asked(node, properties.keys())
@@ -766,6 +1263,8 @@ func set_node_properties(args: Dictionary) -> Dictionary:
766
1263
  "%s would load with editableChildren %s."
767
1264
  % [new_path, not GdhRead.as_bool(editable)]
768
1265
  )
1266
+ if unkept.is_empty() and placeholder != null:
1267
+ unkept = _placeholder_unkept(saved, new_path, GdhRead.as_bool(placeholder))
769
1268
  return unkept
770
1269
  )
771
1270
  if not written.is_empty():
@@ -774,6 +1273,8 @@ func set_node_properties(args: Dictionary) -> Dictionary:
774
1273
 
775
1274
  if editable != null:
776
1275
  answer["editableChildren"] = GdhRead.as_bool(editable)
1276
+ if placeholder != null:
1277
+ answer["placeholder"] = GdhRead.as_bool(placeholder)
777
1278
  if not dropped.is_empty():
778
1279
  answer["droppedValues"] = dropped["values"]
779
1280
  answer["droppedNodes"] = dropped["nodes"]
@@ -951,6 +1452,7 @@ func get_node_properties(args: Dictionary) -> Dictionary:
951
1452
  if node != scene.root and node.owner != null and not node.scene_file_path.is_empty():
952
1453
  answer["instanceOf"] = node.scene_file_path
953
1454
  answer["editableChildren"] = scene.root.is_editable_instance(node)
1455
+ answer["placeholder"] = node.get_scene_instance_load_placeholder()
954
1456
  scene.close()
955
1457
  # Only an editor holds anything unsaved. A headless engine answering false would say it looked.
956
1458
  if Engine.is_editor_hint():
@@ -1062,3 +1564,12 @@ func _save_open_scene(scene_path: String) -> Dictionary:
1062
1564
  "from": "editor",
1063
1565
  "unsavedInEditor": GdhSceneFile.unsaved(scene_path),
1064
1566
  }
1567
+
1568
+
1569
+ ## A node of a branch being saved as its own scene, held by a property outside it, by its path from
1570
+ ## the branch's top, until the instance replacing the branch gives the node at that path.
1571
+ class GdhBranchMark:
1572
+ var below: NodePath
1573
+
1574
+ func _init(at: NodePath) -> void:
1575
+ below = at
@@ -76,6 +76,8 @@ func capture_viewport(params: Dictionary) -> Dictionary:
76
76
  # refuses that state itself.
77
77
  if mode == SubViewport.UPDATE_ALWAYS or not _host.get_tree().root.can_draw():
78
78
  return await _capture(viewport, params)
79
+ # Asked for before the wait: in low-processor mode a still game draws no frame of its own, and
80
+ # this request is what makes it draw the one waited for.
79
81
  sub.render_target_update_mode = SubViewport.UPDATE_ONCE
80
82
  await RenderingServer.frame_post_draw
81
83
  var answer: Dictionary = await _capture(viewport, params)
@@ -23,6 +23,7 @@ const SCENE_COMMANDS: Array[String] = [
23
23
  "set_node_properties",
24
24
  "duplicate_node",
25
25
  "reparent_node",
26
+ "save_branch",
26
27
  "delete_node",
27
28
  "bake_navigation",
28
29
  ]
@@ -186,6 +186,7 @@ static func _lay(
186
186
  elif state.is_node_instance_placeholder(index):
187
187
  nodes[path] = _made("InstancePlaceholder")
188
188
  nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
189
+ nodes[path]["placeholder"] = true
189
190
  elif str(state.get_node_type(index)).is_empty():
190
191
  # An entry for a node of an instance that no longer has it, which the engine skips
191
192
  # too.
@@ -367,6 +368,8 @@ static func _entry(
367
368
  data["instanceOf"] = node["instanceOf"]
368
369
  if node.has("editableChildren"):
369
370
  data["editableChildren"] = node["editableChildren"]
371
+ if node.has("placeholder"):
372
+ data["placeholder"] = true
370
373
  # An inherited scene's file holds only what it changes, so the read says what it inherits and
371
374
  # which nodes come from there, as the editor greys them.
372
375
  if node.has("inherits"):