gdharness 1.3.45 → 1.3.47

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.
@@ -560,11 +560,12 @@ func reparent_node(args: Dictionary) -> Dictionary:
560
560
  GdhPlacing.own(node, root)
561
561
  if GdhRead.as_bool(args.get("keepGlobalTransform", true)):
562
562
  GdhPlacing.keep_in_place(node, old_parent)
563
- # Unasked, a headless write takes the move on a node whose script is not a tool for the
564
- # script's doing and puts the node back as the file held it.
565
- var placed: Array[String] = GdhHeadlessCheck.differing(node, stored_before, {})
566
- if not placed.is_empty():
567
- scene.asked(node, placed)
563
+ # Unasked, a headless write takes the move on a node whose script is not a tool for the script's
564
+ # doing and puts the node back as the file held it. A new parent changes more than where the
565
+ # node is drawn: a container gives a Control its own layout mode.
566
+ var placed: Array[String] = GdhHeadlessCheck.differing(node, stored_before, {})
567
+ if not placed.is_empty():
568
+ scene.asked(node, placed)
568
569
  var also_changed: Dictionary = {}
569
570
  for property: String in drawn_at:
570
571
  var now: Variant = _properties.read_at(node, property)
@@ -572,6 +573,18 @@ func reparent_node(args: Dictionary) -> Dictionary:
572
573
  also_changed[property] = {
573
574
  "was": _readable(node, drawn_at[property]), "now": _readable(node, now),
574
575
  }
576
+ var drawn_in: Array[String] = GdhPlacing.placed_in(node)
577
+ for property: String in placed:
578
+ if property in drawn_in or also_changed.has(property):
579
+ continue
580
+ var was: Variant = (
581
+ stored_before[property]
582
+ if stored_before.has(property)
583
+ else GdhHeadlessCheck.default_of(node, property)
584
+ )
585
+ also_changed[property] = {
586
+ "was": _readable(node, was), "now": _readable(node, node.get(property)),
587
+ }
575
588
  var sheared: String = ""
576
589
  if node is Node3D and GdhPlacing.sheared((node as Node3D).basis):
577
590
  var spatial: Node3D = node
@@ -705,6 +718,15 @@ func set_node_properties(args: Dictionary) -> Dictionary:
705
718
  if property != "name" and property in node and not named.has(property):
706
719
  named.append(property)
707
720
  var source: String = scene.values_from(node, old_path)
721
+ # Read before the write, which frees the node.
722
+ var unsaved: Array[String] = []
723
+ for declared: Dictionary in node.get_property_list():
724
+ var declared_name: String = str(declared.get("name", ""))
725
+ if (
726
+ named.has(declared_name)
727
+ and not GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE
728
+ ):
729
+ unsaved.append(declared_name)
708
730
  var carried: GdhPaths = GdhPaths.new()
709
731
  var _rewritten: int = carried.carry(
710
732
  scene,
@@ -738,7 +760,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
738
760
  answer["droppedNodes"] = dropped["nodes"]
739
761
  _note_put_back(answer, scene, id, new_path)
740
762
  _note_loads_as(answer, scene, new_path)
741
- _note_left_out(answer, scene.written_pack, new_path, named, source)
763
+ _note_left_out(answer, scene.written_pack, new_path, named, unsaved, source)
742
764
  if new_path != old_path:
743
765
  answer.merge(GdhKin.follow(scene_path, old_path, new_path, carried))
744
766
  return answer
@@ -780,9 +802,15 @@ static func _note_put_back(answer: Dictionary, scene: GdhSceneFile, id: int, at:
780
802
 
781
803
  ## Notes under notStored each of [param named] the file saved from [param pack] does not set on the
782
804
  ## node at [param at]: a set gave it the value the node has without it, so the packer leaves it out,
783
- ## as the editor does, and it follows [param source], or its default, from then on.
805
+ ## as the editor does, and it follows [param source], or its default, from then on. [param unsaved]
806
+ ## are those of them the node never stores by name.
784
807
  static func _note_left_out(
785
- answer: Dictionary, pack: PackedScene, at: String, named: Array[String], source: String
808
+ answer: Dictionary,
809
+ pack: PackedScene,
810
+ at: String,
811
+ named: Array[String],
812
+ unsaved: Array[String],
813
+ source: String
786
814
  ) -> void:
787
815
  var stored: Dictionary = {}
788
816
  var state: SceneState = pack.get_state()
@@ -790,9 +818,13 @@ static func _note_left_out(
790
818
  if str(state.get_node_path(index)).trim_prefix("./") == at:
791
819
  for property: int in state.get_node_property_count(index):
792
820
  stored[str(state.get_node_property_name(index, property))] = true
821
+ # A property the file never holds by name, as a Control's position, which its offsets hold, is
822
+ # stored when what changed with it is.
823
+ var also: Dictionary = answer.get("alsoChanged", {})
824
+ var held_with_it: bool = also.keys().any(func(other: Variant) -> bool: return stored.has(other))
793
825
  var left: Array[String] = []
794
826
  for asked: String in named:
795
- if not stored.has(asked):
827
+ if not stored.has(asked) and not (held_with_it and unsaved.has(asked)):
796
828
  left.append(asked)
797
829
  if left.is_empty():
798
830
  return
@@ -92,6 +92,19 @@ func connect_signal(args: Dictionary) -> Dictionary:
92
92
  if unbinds > 0:
93
93
  callable = callable.unbind(unbinds)
94
94
  var method_found: bool = target.has_method(method_name)
95
+ if not method_found and (not binds.is_empty() or unbinds > 0):
96
+ var named_to: String = str(target.name) if target == scene.root else target_node_path
97
+ return scene.refuse(
98
+ (
99
+ (
100
+ "%s has no method %s, and the engine connects a signal that binds or unbinds "
101
+ + "arguments only to a method its target has, so the scene would load without "
102
+ + "the connection. Add the method to the target's script first, or connect "
103
+ + "without binds and unbinds."
104
+ )
105
+ % [named_to, method_name]
106
+ )
107
+ )
95
108
  var misfit: String = _misfit(source, signal_name, callable, flags)
96
109
  if not before.is_empty():
97
110
  var held: Callable = before["callable"]
@@ -102,6 +115,7 @@ func connect_signal(args: Dictionary) -> Dictionary:
102
115
 
103
116
  var from: String = scene.path_of(source)
104
117
  var to: String = scene.path_of(target)
118
+ var _replaced: Dictionary = scene.drop_unmade(from, signal_name, to, method_name)
105
119
  var saved: Dictionary = {}
106
120
  var written: Dictionary = scene.write(
107
121
  func(loaded: Node) -> String:
@@ -464,7 +478,11 @@ func disconnect_signal(args: Dictionary) -> Dictionary:
464
478
  # A connection that was never there saved the scene unchanged and answered as removed, so a
465
479
  # typo in either name read as success.
466
480
  var held: Dictionary = _saved(scene.root, from, signal_name, to, method_name)
467
- if held.is_empty():
481
+ # One the engine could not make is in the file alone, and the write drops it.
482
+ var unmade: Dictionary = scene.drop_unmade(from, signal_name, to, method_name)
483
+ if held.is_empty() and not unmade.is_empty():
484
+ held = { "flags": unmade["flags"], "binds": unmade["binds"], "unbinds": unmade["unbinds"] }
485
+ elif held.is_empty():
468
486
  # The root's path is ".", which before ".method" read as "..method".
469
487
  var said_from: String = str(source.name) if source == scene.root else from
470
488
  var said_to: String = str(target.name) if target == scene.root else to
@@ -492,8 +510,9 @@ func disconnect_signal(args: Dictionary) -> Dictionary:
492
510
  )
493
511
  )
494
512
  )
495
- var callable: Callable = held["callable"]
496
- source.disconnect(signal_name, callable)
513
+ if held.has("callable"):
514
+ var callable: Callable = held["callable"]
515
+ source.disconnect(signal_name, callable)
497
516
 
498
517
  var written: Dictionary = scene.write(
499
518
  func(loaded: Node) -> String:
@@ -303,6 +303,13 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
303
303
  wanted.append([at, source_id, atlas_coords, alternative])
304
304
  if source_id == -1:
305
305
  erased += 1
306
+ # An erased cell leaves the layer's data at its next update, which a layer outside the tree
307
+ # never has, so the pack wrote it as a cell naming no tile, where the editor's save leaves it
308
+ # out.
309
+ if layered != null:
310
+ layered.update_internals()
311
+ else:
312
+ single.update_internals()
306
313
 
307
314
  var at_path: String = scene.path_of(node)
308
315
  var written: Dictionary = scene.write(
@@ -62,6 +62,15 @@ func set_property(params: Dictionary) -> Dictionary:
62
62
  "type": "error",
63
63
  "message": GdhPaths.nothing_there(holder, named, str(reached["called"])),
64
64
  }
65
+ if GdhPaths.is_constant(holder, named):
66
+ return {
67
+ "type": "error",
68
+ "message":
69
+ (
70
+ "%s:%s is a constant its script declares, and a constant cannot be set"
71
+ % [reached["called"], named]
72
+ ),
73
+ }
65
74
  var old_value: Variant = GdhPaths.read_under(holder, named)
66
75
  var slot: Dictionary = GdhValues.slot_declared(holder, named)
67
76
  var declared: int = slot["type"]
@@ -503,7 +503,7 @@ static func can_read(holder: Variant, named: String) -> bool:
503
503
  var object: Object = holder
504
504
  if not method.is_empty():
505
505
  return call_refused(object, method, "", args.size()).is_empty()
506
- return _has_property(object, named)
506
+ return _has_property(object, named) or not constant_of(object, named).is_empty()
507
507
  return false
508
508
 
509
509
 
@@ -876,7 +876,7 @@ static func read_under(holder: Variant, named: String) -> Variant:
876
876
  if not method.is_empty():
877
877
  var args: Array = arguments_of(named)["args"]
878
878
  return object.callv(method, args)
879
- return object.get(named)
879
+ return constant_of(object, named)[0] if is_constant(object, named) else object.get(named)
880
880
 
881
881
 
882
882
  ## Where [param named] lands in a list of [param size], or -1 for a step that is not in it.
@@ -1030,3 +1030,30 @@ static func _has_property(holder: Object, named: String) -> bool:
1030
1030
  if str(entry["name"]) == named:
1031
1031
  return true
1032
1032
  return false
1033
+
1034
+
1035
+ ## The constant [param named] that [param holder]'s script or a script it extends declares, in a
1036
+ ## list of one, or an empty list. A constant is no property, so the property list never names it,
1037
+ ## and `get` answers null for one.
1038
+ static func constant_of(holder: Variant, named: String) -> Array:
1039
+ if not holder is Object:
1040
+ return []
1041
+ var object: Object = holder
1042
+ var attached: Variant = object.get_script()
1043
+ if not attached is Script:
1044
+ return []
1045
+ var script: Script = attached
1046
+ while script != null:
1047
+ var constants: Dictionary = script.get_script_constant_map()
1048
+ if constants.has(named):
1049
+ return [constants[named]]
1050
+ script = script.get_base_script()
1051
+ return []
1052
+
1053
+
1054
+ ## Whether [param named] reaches a constant on [param holder] rather than a property.
1055
+ static func is_constant(holder: Variant, named: String) -> bool:
1056
+ if not holder is Object:
1057
+ return false
1058
+ var object: Object = holder
1059
+ return not _has_property(object, named) and not constant_of(object, named).is_empty()
@@ -17,6 +17,11 @@ const MOST_FRAMES: int = 600
17
17
  ## How long a wait for words leaves between looks, as a multiple of what the last look took: nine
18
18
  ## gives looking about a tenth of the time.
19
19
  const LOOKS_APART: int = 9
20
+ ## The longest a wait for words holds one of the game's frames while it looks, in microseconds: a
21
+ ## look over a screen too large to read in that time carries on in the next frame from where it
22
+ ## stopped. Read whole, a look over an interface of about 1,000 labels held a frame for 28ms every
23
+ ## few dozen, which a game logging its own frame times took for a hitch of its own.
24
+ const LOOK_SLICE_USEC: int = 2000
20
25
  ## How a property wait compares what it reads with the value: equality by default, and order for a
21
26
  ## number that steps rather than lands, as a day counter on a fast clock goes from 31 to 34 between
22
27
  ## two looks and a wait for 32 never sees it.
@@ -785,9 +790,12 @@ static func _declared_slot(holder: Variant, named: String) -> Dictionary:
785
790
  ## looking takes about a tenth of the time. Every frame on a hall of 3,500 nodes held the game at a
786
791
  ## sixth of its frame rate, and its clock, which advances by the frame's delta, lost three quarters
787
792
  ## of its time, so a wait for a date timed out on a clock that turned a day every thirty seconds.
788
- ## The answer says how many frames the wait spanned, how many looks it took and how long they took
789
- ## between them, which is how a caller sees what it cost and whether the game lost its speed to the
790
- ## looks or to something else.
793
+ ## Each look is read a slice at a time, [constant LOOK_SLICE_USEC] to a frame, so the frames it runs
794
+ ## in keep their length; the last is read whole, since the wait answers on it.
795
+ ##
796
+ ## The answer says how many frames the wait spanned, how many looks it took, how long they took
797
+ ## between them and the longest they held one frame, which is how a caller sees what it cost and
798
+ ## whether the game lost its speed, or a frame its length, to the looks or to something else.
791
799
  func _wait_until_said(
792
800
  node_path: String, said: String, timeout_ms: int, include_hidden: bool
793
801
  ) -> Dictionary:
@@ -795,18 +803,29 @@ func _wait_until_said(
795
803
  var frames: int = 0
796
804
  var looks: int = 0
797
805
  var looked_usec: int = 0
806
+ var held_usec: int = 0
798
807
  var next_look: int = 0
799
808
  var looked_at_frame: int = 0
800
809
  var found: bool = false
810
+ var look: GdhLook = null
811
+ var look_began: int = 0
801
812
  while true:
802
- if Time.get_ticks_usec() >= next_look:
803
- var looking: int = Time.get_ticks_usec()
804
- found = _anything_says(node_path, said, include_hidden)
805
- looks += 1
806
- var took: int = Time.get_ticks_usec() - looking
807
- looked_usec += took
808
- next_look = Time.get_ticks_usec() + took * LOOKS_APART
813
+ if look == null and Time.get_ticks_usec() >= next_look:
814
+ look = GdhLook.new(_under(node_path), said, include_hidden)
815
+ look_began = Time.get_ticks_usec()
816
+ if look != null:
817
+ var held: int = look.go_on(LOOK_SLICE_USEC)
818
+ looked_usec += held
819
+ held_usec = maxi(held_usec, held)
809
820
  looked_at_frame = frames
821
+ if look.done:
822
+ looks += 1
823
+ found = look.found
824
+ # From where the look began, since the frames it was read across are the game's:
825
+ # counted from its end, a look spread over a third of a second waited out its nine
826
+ # lengths after that, and a wait on 3,500 nodes looked four times in two seconds.
827
+ next_look = look_began + look.took_usec * (LOOKS_APART + 1)
828
+ look = null
810
829
  if found or Time.get_ticks_msec() - started >= timeout_ms:
811
830
  break
812
831
  await _host.get_tree().process_frame
@@ -815,14 +834,18 @@ func _wait_until_said(
815
834
  # never having come. Taken once a whole frame has run since the last look, which is two frame
816
835
  # starts: a first look that outlasted the timeout otherwise left none, so the wait answered with
817
836
  # the game not having run at all, and a command sent during that look not yet read.
837
+ var heard: bool = found
818
838
  if not found:
819
839
  while frames - looked_at_frame < 2:
820
840
  await _host.get_tree().process_frame
821
841
  frames += 1
822
- var last: int = Time.get_ticks_usec()
823
- found = _anything_says(node_path, said, include_hidden)
842
+ var last: GdhLook = GdhLook.new(_under(node_path), said, include_hidden)
843
+ var held: int = last.go_on(-1)
824
844
  looks += 1
825
- looked_usec += Time.get_ticks_usec() - last
845
+ looked_usec += held
846
+ held_usec = maxi(held_usec, held)
847
+ found = last.found
848
+ heard = last.heard
826
849
 
827
850
  var answer: Dictionary = {
828
851
  "type": "condition",
@@ -834,10 +857,11 @@ func _wait_until_said(
834
857
  "frames": frames,
835
858
  "looks": looks,
836
859
  "looking_ms": looked_usec / 1000.0,
860
+ "longest_hold_ms": held_usec / 1000.0,
837
861
  }
838
862
  # Words that are there and cannot be read look, from a wait that ran out, like words that never
839
863
  # came.
840
- if not found and not include_hidden and _anything_says(node_path, said, true):
864
+ if not found and heard:
841
865
  answer["unseen_note"] = (
842
866
  (
843
867
  "Something under %s says it, but is hidden or drawn outside the viewport or a "
@@ -849,22 +873,8 @@ func _wait_until_said(
849
873
  return answer
850
874
 
851
875
 
852
- ## Whether anything under [param node_path] says [param said], the node itself included, and where
853
- ## the player can read it unless hidden ones are wanted too. Matched as a find and a click match
854
- ## `says`, a glob over the whole text when it has `*` or `?`: read as literal words, a glob a click
855
- ## had just pressed by was waited on for the whole timeout with its label on screen, and answered as
856
- ## words never arriving.
857
- func _anything_says(node_path: String, said: String, include_hidden: bool) -> bool:
858
- var root: Node = _host.get_tree().root.get_node_or_null(node_path)
859
- if root == null:
860
- return false
861
- var pending: Array[Node] = [root]
862
- while not pending.is_empty():
863
- var node: Node = pending.pop_back()
864
- if GdhSays.says(node, said) and (include_hidden or GdhQueries.readable(node)):
865
- return true
866
- pending.append_array(node.get_children(true))
867
- return false
876
+ func _under(node_path: String) -> Node:
877
+ return _host.get_tree().root.get_node_or_null(node_path)
868
878
 
869
879
 
870
880
  ## Remembers that a signal fired and what it carried, for a wait that polls rather than
@@ -942,6 +952,56 @@ class GdhTrace:
942
952
  dropped += 1
943
953
 
944
954
 
955
+ ## One look for whether anything under a node says some words, the node itself included, and where
956
+ ## the player can read it unless hidden ones are wanted too. Matched as a find and a click match
957
+ ## `says`, a glob over the whole text when it has `*` or `?`: read as literal words, a glob a click
958
+ ## had just pressed by was waited on for the whole timeout with its label on screen, and answered as
959
+ ## words never arriving.
960
+ class GdhLook:
961
+ extends RefCounted
962
+ ## Whether a node that counts says the words.
963
+ var found: bool = false
964
+ ## Whether any node says them, hidden or not: words that are there and cannot be read look,
965
+ ## from a wait that ran out, like words that never came.
966
+ var heard: bool = false
967
+ var done: bool = false
968
+ ## How long the look has taken over every frame it has run in.
969
+ var took_usec: int = 0
970
+ var _said: String
971
+ var _include_hidden: bool
972
+ ## Untyped, since a node waiting here can be freed between two frames of the look.
973
+ var _pending: Array = []
974
+
975
+ func _init(root: Node, said: String, include_hidden: bool) -> void:
976
+ _said = said
977
+ _include_hidden = include_hidden
978
+ if root != null:
979
+ _pending.append(root)
980
+
981
+ ## Reads on until the look is done or [param budget_usec] has passed, -1 for no limit, and
982
+ ## answers how long it read.
983
+ func go_on(budget_usec: int) -> int:
984
+ var from: int = Time.get_ticks_usec()
985
+ while not _pending.is_empty():
986
+ if budget_usec >= 0 and Time.get_ticks_usec() - from >= budget_usec:
987
+ break
988
+ var next: Variant = _pending.pop_back()
989
+ if not is_instance_valid(next):
990
+ continue
991
+ var node: Node = next
992
+ if GdhSays.says(node, _said):
993
+ heard = true
994
+ if _include_hidden or GdhQueries.readable(node):
995
+ found = true
996
+ _pending.clear()
997
+ break
998
+ _pending.append_array(node.get_children(true))
999
+ done = _pending.is_empty()
1000
+ var read: int = Time.get_ticks_usec() - from
1001
+ took_usec += read
1002
+ return read
1003
+
1004
+
945
1005
  ## A signal recorded from when it was armed until it is collected.
946
1006
  class GdhWatch:
947
1007
  extends RefCounted
@@ -11,6 +11,11 @@ const LOOP_MODE_NAMES: Dictionary[int, String] = {
11
11
  Animation.LOOP_LINEAR: "linear",
12
12
  Animation.LOOP_PINGPONG: "pingpong",
13
13
  }
14
+ const UPDATE_MODE_NAMES: Dictionary[int, String] = {
15
+ Animation.UPDATE_CONTINUOUS: "continuous",
16
+ Animation.UPDATE_DISCRETE: "discrete",
17
+ Animation.UPDATE_CAPTURE: "capture",
18
+ }
14
19
  const TRACK_TYPE_NAMES: Dictionary[int, String] = {
15
20
  Animation.TYPE_VALUE: "property",
16
21
  Animation.TYPE_POSITION_3D: "position_3d",
@@ -166,6 +171,9 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
166
171
  var property: String = str(path.get_concatenated_subnames())
167
172
  if not property.is_empty():
168
173
  shaped["property"] = property
174
+ if kind == Animation.TYPE_VALUE:
175
+ var mode: int = anim.value_track_get_update_mode(track)
176
+ shaped["update"] = UPDATE_MODE_NAMES.get(mode, str(mode))
169
177
  var keyframes: Array[Dictionary] = []
170
178
  var methods: Dictionary = {}
171
179
  for key: int in anim.track_get_key_count(track):
@@ -81,6 +81,8 @@ static func run(params: Dictionary) -> Dictionary:
81
81
  var answered: Dictionary = answer
82
82
  if not GdhSceneFile.also_saved.is_empty():
83
83
  answered["alsoSaved"] = GdhSceneFile.also_saved.duplicate(true)
84
+ if not GdhSceneFile.unmade_note.is_empty():
85
+ answered["unmadeConnections"] = GdhSceneFile.unmade_note
84
86
  if not GdhSceneUids.by_path.is_empty():
85
87
  answered["namedByPath"] = (
86
88
  (
@@ -121,6 +121,26 @@ static func _runnable(config: ConfigFile, section: String) -> bool:
121
121
  return GdhRead.as_bool(config.get_value(section, "runnable", false))
122
122
 
123
123
 
124
+ ## The name of the preset [param platform] runs, or "" when it has none: the one its section names,
125
+ ## or in a file saved before that section, the last preset of the platform keeping the key, which is
126
+ ## the one the editor ends up running. Read from the section alone, a file of that age had no
127
+ ## runnable preset, so a preset added for its platform took the place of the one it had.
128
+ static func _runnable_name(config: ConfigFile, platform: String) -> String:
129
+ if config.has_section_key(RUNNABLE_SECTION, platform):
130
+ return str(config.get_value(RUNNABLE_SECTION, platform))
131
+ var named: String = ""
132
+ var at: int = 0
133
+ while config.has_section("preset." + str(at)):
134
+ var section: String = "preset." + str(at)
135
+ if (
136
+ str(config.get_value(section, "platform", "")) == platform
137
+ and GdhRead.as_bool(config.get_value(section, "runnable", false))
138
+ ):
139
+ named = str(config.get_value(section, "name", ""))
140
+ at += 1
141
+ return named
142
+
143
+
124
144
  ## Adds an export preset to export_presets.cfg, written with the keys the editor's Project > Export
125
145
  ## writes for a new one, and every option in [code]known_options[/code] (what
126
146
  ## [method export_options] answers for the platform) at its default. The editor also writes the
@@ -160,7 +180,7 @@ func add_export_preset(params: Dictionary) -> Dictionary:
160
180
  )
161
181
  index += 1
162
182
  # A platform with no preset to run takes the new one, as the editor's dialog does.
163
- var runnable: bool = not config.has_section_key(RUNNABLE_SECTION, platform)
183
+ var runnable: bool = _runnable_name(config, platform).is_empty()
164
184
  if runnable:
165
185
  config.set_value(RUNNABLE_SECTION, platform, preset_name)
166
186
 
@@ -609,6 +629,7 @@ func remove_export_preset(params: Dictionary) -> Dictionary:
609
629
  var index: int = found["index"]
610
630
  var section: String = found["section"]
611
631
  var platform: String = str(config.get_value(section, "platform", ""))
632
+ var was_runnable: bool = _runnable_name(config, platform) == preset_name
612
633
  var last: int = index
613
634
  while config.has_section("preset." + str(last + 1)):
614
635
  last += 1
@@ -622,11 +643,17 @@ func remove_export_preset(params: Dictionary) -> Dictionary:
622
643
  if keeping != OK:
623
644
  return _log.failure("Failed to save %s: %s" % [CREDENTIALS_FILE, error_string(keeping)])
624
645
  answer["credentials"] = CREDENTIALS_FILE
625
- if str(config.get_value(RUNNABLE_SECTION, platform, "")) == preset_name:
626
- config.set_value(RUNNABLE_SECTION, platform, null)
627
- answer["runnableNow"] = null
646
+ if was_runnable:
647
+ if config.has_section_key(RUNNABLE_SECTION, platform):
648
+ config.set_value(RUNNABLE_SECTION, platform, null)
649
+ # A file saved before the section can mark another preset of the platform too, which the
650
+ # editor then runs.
651
+ var kept: String = _runnable_name(config, platform)
652
+ answer["runnableNow"] = kept
653
+ if kept.is_empty():
654
+ answer["runnableNow"] = null
628
655
  var at: int = 0
629
- while config.has_section("preset." + str(at)):
656
+ while kept.is_empty() and config.has_section("preset." + str(at)):
630
657
  if str(config.get_value("preset." + str(at), "platform", "")) == platform:
631
658
  var next: String = str(config.get_value("preset." + str(at), "name", ""))
632
659
  config.set_value(RUNNABLE_SECTION, platform, next)
@@ -96,6 +96,11 @@ static func without_annotations(line: String) -> String:
96
96
  # brace opens before the `#` and closes on a later line. Held by a case, since the argument for the
97
97
  # difference is not visible from the code and reads like an oversight beside its neighbours.
98
98
  static func without_comment(line: String) -> String:
99
+ var start: int = _comment_start(line)
100
+ return (line if start == -1 else line.substr(0, start)).strip_edges()
101
+
102
+
103
+ static func _comment_start(line: String) -> int:
99
104
  var quote: String = ""
100
105
  for i: int in range(line.length()):
101
106
  var ch: String = line[i]
@@ -104,10 +109,30 @@ static func without_comment(line: String) -> String:
104
109
  quote = ""
105
110
  continue
106
111
  if ch == "#":
107
- return line.substr(0, i).strip_edges()
112
+ return i
108
113
  if ch == '"' or ch == "'":
109
114
  quote = ch
110
- return line.strip_edges()
115
+ return -1
116
+
117
+
118
+ ## The code in front of each TODO or FIXME comment in [param content], one entry a comment.
119
+ ##
120
+ ## Read from the comments alone, as the editor highlights them, so `#TODO` counts and the same
121
+ ## words inside a string or a multi-line string do not.
122
+ static func noted_work(content: String) -> Array[String]:
123
+ var noted: Array[String] = []
124
+ var in_string: bool = false
125
+ for line: String in content.split("\n"):
126
+ if in_string:
127
+ in_string = triple_quotes(line) % 2 == 0
128
+ continue
129
+ var start: int = _comment_start(line)
130
+ var code: String = line if start == -1 else line.substr(0, start)
131
+ if triple_quotes(code) % 2 == 1:
132
+ in_string = true
133
+ elif start != -1 and compiled("^#+\\s*(?:TODO|FIXME)\\b").search(line.substr(start)):
134
+ noted.append(code.strip_edges())
135
+ return noted
111
136
 
112
137
 
113
138
  ## How many triple quotes of either kind [param line] holds. An odd count opens or closes a string
@@ -5,6 +5,7 @@ const GdhFileWalk = preload("file_walk.gd")
5
5
  const GdhImportPipeline = preload("import_pipeline.gd")
6
6
  const GdhInputActions = preload("input_actions.gd")
7
7
  const GdhLog = preload("logger.gd")
8
+ const GdhPatterns = preload("patterns.gd")
8
9
  const GdhRead = preload("reading.gd")
9
10
  const GdhSceneConnections = preload("scene_connections.gd")
10
11
  const GdhScriptCheck = preload("script_check.gd")
@@ -87,7 +88,7 @@ func get_project_health(params: Dictionary) -> Dictionary:
87
88
  var content: String = file.get_as_text()
88
89
  file.close()
89
90
 
90
- if "# TODO" in content or "# FIXME" in content:
91
+ if not GdhPatterns.noted_work(content).is_empty():
91
92
  todo_count += 1
92
93
 
93
94
  # Work noted for later, which no script fails on, so said rather than failed. A count of
@@ -8,6 +8,7 @@ const GdhFileWalk = preload("file_walk.gd")
8
8
  const GdhImportPipeline = preload("import_pipeline.gd")
9
9
  const GdhInputActions = preload("input_actions.gd")
10
10
  const GdhLog = preload("logger.gd")
11
+ const GdhPatterns = preload("patterns.gd")
11
12
  const GdhRead = preload("reading.gd")
12
13
  const GdhSceneConnections = preload("scene_connections.gd")
13
14
  const GdhScriptCheck = preload("script_check.gd")
@@ -205,11 +206,12 @@ func validate_project(params: Dictionary) -> Dictionary:
205
206
  var content: String = file.get_as_text()
206
207
  file.close()
207
208
 
208
- if "# TODO" in content or "# FIXME" in content:
209
+ var noted: Array[String] = GdhPatterns.noted_work(content)
210
+ if not noted.is_empty():
209
211
  script_issues.append(
210
212
  { "path": script_path, "issue": "Contains TODO/FIXME comments" }
211
213
  )
212
- if "pass # TODO" in content:
214
+ if "pass" in noted:
213
215
  script_issues.append(
214
216
  { "path": script_path, "issue": "Contains unimplemented functions" }
215
217
  )
package/build/keeper.js CHANGED
@@ -8895,6 +8895,7 @@ function frameEndLine() {
8895
8895
  return frameEnd;
8896
8896
  }
8897
8897
  var READ_MS = 5000;
8898
+ var EVALUATE_MS = 60000;
8898
8899
  var REPLIES = new Set([
8899
8900
  "debug_enter",
8900
8901
  "debug_exit",
@@ -9478,10 +9479,10 @@ class GameDebugger {
9478
9479
  }
9479
9480
  this.forget("evaluation_return");
9480
9481
  this.send("evaluate", [expression, frame], held.thread);
9481
- const answered = await this.next(GameDebugger.named("evaluation_return"), READ_MS);
9482
+ const answered = await this.next(GameDebugger.named("evaluation_return"), EVALUATE_MS);
9482
9483
  if (answered === null || !("data" in answered)) {
9483
9484
  return {
9484
- refused: `The game worked out nothing for ${expression} within ${READ_MS / 1000}s. Ask again.`
9485
+ refused: `The game worked out nothing for ${expression} within ${EVALUATE_MS / 1000}s. The first expression a game evaluates loads the script of every global class in the project first, static initialisers included, so one of those may still be running. Ask again.`
9485
9486
  };
9486
9487
  }
9487
9488
  const [, , type, value, hint] = answered.data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.45",
3
+ "version": "1.3.47",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",