gdharness 1.3.34 → 1.3.36

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 (30) hide show
  1. package/build/cli.js +288 -59
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +17 -1
  3. package/build/godot/addons/gdharness_editor/reading.gd +10 -2
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +12 -6
  5. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +57 -0
  6. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +16 -0
  7. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +81 -4
  8. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +3 -3
  9. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -2
  10. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +59 -0
  11. package/build/godot/addons/gdharness_editor/tools/script_paths.gd +148 -0
  12. package/build/godot/addons/gdharness_editor/tools/script_paths.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_runtime/reading.gd +10 -2
  14. package/build/godot/addons/gdharness_runtime/runtime_hover.gd +70 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_input.gd +223 -248
  16. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +128 -5
  17. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +107 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +15 -1
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +8 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_words.gd +22 -0
  21. package/build/godot/operations/animation_reads.gd +17 -1
  22. package/build/godot/operations/classdb_queries.gd +16 -0
  23. package/build/godot/operations/dependencies.gd +46 -4
  24. package/build/godot/operations/editor_tools.gd +5 -5
  25. package/build/godot/operations/gdscript_authoring.gd +7 -3
  26. package/build/godot/operations/input_actions.gd +105 -0
  27. package/build/godot/operations/project_diagnostics.gd +4 -0
  28. package/build/godot/operations/project_validation.gd +15 -0
  29. package/build/godot/operations/reading.gd +10 -2
  30. package/package.json +1 -1
@@ -4,6 +4,9 @@ extends RefCounted
4
4
  ## here and sent from runtime_input.gd, which keeps where the pointer is.
5
5
 
6
6
  const GdhRead = preload("reading.gd")
7
+ const GdhScreen = preload("runtime_screen.gd")
8
+ const GdhTargets = preload("runtime_targets.gd")
9
+ const GdhValues = preload("runtime_values.gd")
7
10
 
8
11
 
9
12
  ## A point the tool schema sends as two flat numbers. Answers a Vector2, or the String that says
@@ -14,6 +17,84 @@ static func read_point(params: Dictionary, x_key: String, y_key: String) -> Vari
14
17
  return Vector2(GdhRead.as_float(params[x_key]), GdhRead.as_float(params[y_key]))
15
18
 
16
19
 
20
+ ## The point a click names in [param node]'s own space, null when it names none, or the String
21
+ ## refusing it: a 3D node is clicked where it is drawn, and an item found by its words, a tab or a
22
+ ## row, where the control draws it.
23
+ ##
24
+ ## For a control that draws its parts itself and hit-tests them in _gui_input, whose centre was all
25
+ ## a click could press; in a native window, where the game's window pixels do not reach, nothing
26
+ ## else could press the rest.
27
+ static func point_in(params: Dictionary, node: Node, node_path: String, has_item: bool) -> Variant:
28
+ if not params.has("x") and not params.has("y"):
29
+ return null
30
+ if node is Node3D:
31
+ return (
32
+ "%s is a 3D node, clicked where it is drawn; x and y name a point in a Control's own space"
33
+ % node_path
34
+ )
35
+ if has_item:
36
+ return (
37
+ (
38
+ "the words name an item %s draws, which is pressed where it is drawn, and x and y a point in it:"
39
+ + " give one of them"
40
+ )
41
+ % node_path
42
+ )
43
+ return read_point(params, "x", "y")
44
+
45
+
46
+ ## The node a mouse_click, mouse_motion or mouse_drag names its points in, as {"node", "path"}, an
47
+ ## empty Dictionary for the game's window, or the String refusing it.
48
+ ##
49
+ ## A point in a node's own space is the only way to press part of a control in a native window,
50
+ ## where the game's window pixels do not reach: a game that draws its buttons itself and hit-tests
51
+ ## them in _gui_input had nothing a click could aim at but the control's centre.
52
+ static func aim_of(params: Dictionary, root: Window) -> Variant:
53
+ var path: String = str(params.get("nodePath", ""))
54
+ if path.is_empty():
55
+ return {}
56
+ var standing: Dictionary = GdhValues.node_at(root, path)
57
+ if standing.has("message"):
58
+ return standing["message"]
59
+ var node: Node = standing["node"]
60
+ if node is CanvasItem or node is Viewport:
61
+ return {"node": node, "path": path}
62
+ return (
63
+ "%s is a %s, and the points are in a Control's, a Node2D's or a Window's own space"
64
+ % [path, node.get_class()]
65
+ )
66
+
67
+
68
+ ## Where [param local], in the space of the node [param aim] names, arrives: {"viewport", "position"},
69
+ ## the viewport the pointer reaches it through and the point in that viewport's pixels, which is what
70
+ ## an event sent there names. A Window's space is its own pixels, as the game's window pixels are the
71
+ ## root's; a canvas item's is its own, the one it draws and reads _gui_input in.
72
+ static func carried(aim: Dictionary, local: Vector2, root: Window) -> Dictionary:
73
+ var node: Node = aim.get("node", root)
74
+ var reached: Dictionary = {}
75
+ if node is Viewport:
76
+ var viewport: Viewport = node
77
+ reached = GdhScreen.reach(viewport, viewport.get_final_transform().affine_inverse() * local)
78
+ else:
79
+ var item: CanvasItem = node
80
+ reached = GdhScreen.reach(item.get_viewport(), item.get_global_transform_with_canvas() * local)
81
+ var arriving: Viewport = reached["viewport"]
82
+ var point: Vector2 = reached["point"]
83
+ return {"viewport": arriving, "position": arriving.get_final_transform() * point}
84
+
85
+
86
+ ## What a pointer answer says of where [param points] went in [param viewport]: the node they were
87
+ ## named in, the window when it is not the game's own, and any of them outside its viewport. Read
88
+ ## before the first event, since a release can close the window it went to.
89
+ static func where(aim: Dictionary, viewport: Viewport, points: Array[Vector2]) -> Dictionary:
90
+ var said: Dictionary = outside_noted({}, viewport, points)
91
+ if aim.has("path"):
92
+ said["path"] = aim["path"]
93
+ if viewport != viewport.get_tree().root:
94
+ said["window"] = str(viewport.get_path())
95
+ return said
96
+
97
+
17
98
  ## The points of a drag: x, y, then each of via, then toX, toY. Answers the String saying what is
18
99
  ## wrong instead, since a point left out would otherwise read as the window's corner.
19
100
  static func stroke_of(params: Dictionary) -> Variant:
@@ -66,9 +147,10 @@ static func mask_of(button: int) -> int:
66
147
 
67
148
 
68
149
  ## A button event carries the buttons held once it has happened: the pressed one among them, the
69
- ## released one out. A wheel step is a button with no mask, as it is from a real mouse.
150
+ ## released one out. A wheel step is a button with no mask, as it is from a real mouse. [param held]
151
+ ## is what was held before it, which Input knows for the game's window.
70
152
  static func button_event(
71
- position: Vector2, button: int, pressed: bool, double: bool
153
+ position: Vector2, button: int, pressed: bool, double: bool, held: int = -1
72
154
  ) -> InputEventMouseButton:
73
155
  var event: InputEventMouseButton = InputEventMouseButton.new()
74
156
  event.position = position
@@ -76,7 +158,8 @@ static func button_event(
76
158
  event.button_index = button as MouseButton
77
159
  event.pressed = pressed
78
160
  event.double_click = double
79
- var held: int = Input.get_mouse_button_mask()
161
+ if held < 0:
162
+ held = Input.get_mouse_button_mask()
80
163
  var own: int = mask_of(button)
81
164
  event.button_mask = (held | own) if pressed else (held & ~own)
82
165
  return event
@@ -90,12 +173,15 @@ static func button_event(
90
173
  ## content scale and any letterbox offset, so the rect is taken into window pixels first.
91
174
  static func outside_noted(answer: Dictionary, viewport: Viewport, points: Array[Vector2]) -> Dictionary:
92
175
  var shown: Rect2 = viewport.get_final_transform() * viewport.get_visible_rect()
176
+ var which: String = (
177
+ "the window's" if viewport == viewport.get_tree().root else "%s's" % viewport.get_path()
178
+ )
93
179
  for point: Vector2 in points:
94
180
  if not shown.has_point(point):
95
181
  answer["outside_viewport"] = (
96
182
  (
97
- "%s is outside the window's viewport, %s in window pixels, so no control there receives"
98
- % [point, shown]
183
+ "%s is outside %s viewport, %s in window pixels, so no control there receives"
184
+ % [point, which, shown]
99
185
  )
100
186
  + " it; the game's _input and _unhandled_input still do."
101
187
  )
@@ -140,3 +226,40 @@ static func button_of(raw: Variant) -> int:
140
226
  # number past the two extra buttons names nothing a mask bit can be made for.
141
227
  var number: int = GdhRead.as_int(raw, -1)
142
228
  return number if number >= MOUSE_BUTTON_LEFT and number <= MOUSE_BUTTON_XBUTTON2 else -1
229
+
230
+
231
+ ## Why [param control] would take no click a player gave it, or "" when it would.
232
+ ##
233
+ ## The last two answered landed, since the pointer reaches the control, while the press did nothing,
234
+ ## measured on 4.7.2: a disabled button keeps its mouse filter and ignores the press, and a control
235
+ ## that does not process while the game is paused is hovered and never handed the event.
236
+ ## A control whose pointer a `mouse_behavior_recursive` above it has turned off is passed through to
237
+ ## what is underneath, which answered `landed: false` with nothing saying why. [param paused] is
238
+ ## whether the game is.
239
+ static func takes_no_click(control: Control, node_path: String, paused: bool) -> String:
240
+ if not control.is_visible_in_tree():
241
+ return "%s is not visible%s, so nothing can click it" % [node_path, GdhTargets.why_hidden(control)]
242
+ var button: BaseButton = control as BaseButton
243
+ if button != null and button.disabled:
244
+ return "%s is disabled, so clicking it presses nothing" % node_path
245
+ var turned_off: Control = GdhTargets.pointer_turned_off_by(control)
246
+ if turned_off != null:
247
+ var on: String = "itself" if turned_off == control else str(turned_off.get_path())
248
+ return (
249
+ (
250
+ "%s takes no pointer input, since mouse_behavior_recursive is disabled on %s,"
251
+ + " so clicking it presses nothing"
252
+ )
253
+ % [node_path, on]
254
+ )
255
+ if not control.can_process():
256
+ var why: String = GdhTargets.why_it_does_not_process(control, node_path)
257
+ var over: Node = GdhTargets.drawn_over(control)
258
+ var cover: String = "" if over == null else "; %s is drawn over it" % over.get_path()
259
+ if paused:
260
+ return (
261
+ "the game is paused and %s does not process while it is (%s), so clicking it does nothing%s"
262
+ % [node_path, why, cover]
263
+ )
264
+ return "%s does not process (%s), so clicking it does nothing%s" % [node_path, why, cover]
265
+ return ""
@@ -3,6 +3,8 @@ extends RefCounted
3
3
  ## Where a point in some viewport is on the game's own screen, for a click to be sent there and for
4
4
  ## an answer to say where something is drawn.
5
5
 
6
+ const GdhHover = preload("runtime_hover.gd")
7
+
6
8
 
7
9
  ## The viewport a pointer at [param point], in [param viewport]'s own coordinates, arrives through,
8
10
  ## and where it is there: {"viewport", "point"}.
@@ -106,3 +108,108 @@ static func shown_part(control: Control) -> Variant:
106
108
  return null
107
109
  part = part.intersection(clip)
108
110
  return part
111
+
112
+
113
+ ## Whether [param centre] is somewhere a click can reach it: inside the viewport, and inside every
114
+ ## ScrollContainer between the control and the root, each of which clips what it holds.
115
+ static func in_sight(control: Control, viewport: Viewport, centre: Vector2) -> bool:
116
+ return viewport.get_visible_rect().has_point(centre) and clipped_by(control, centre) == null
117
+
118
+
119
+ ## The ScrollContainer between [param control] and its viewport that clips [param centre] away from
120
+ ## it, or null when none does. Not past the viewport: one holding a dialog is in another space.
121
+ static func clipped_by(control: Control, centre: Vector2) -> ScrollContainer:
122
+ var walking: Node = control.get_parent()
123
+ while walking != null and not (walking is Viewport):
124
+ var holder: ScrollContainer = walking as ScrollContainer
125
+ # Carried into the same space the centre is in, which is the canvas rather than the
126
+ # container's own: the two are only the same while nothing above it is transformed.
127
+ if holder != null:
128
+ var seen: Rect2 = holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size)
129
+ if not seen.has_point(centre):
130
+ return holder
131
+ walking = walking.get_parent()
132
+ return null
133
+
134
+
135
+ ## Scrolls whatever is holding [param control] until it is on screen, and answers whether the view
136
+ ## moved.
137
+ ##
138
+ ## Innermost container first and outwards, a frame apart. A container moves what it holds on the
139
+ ## next layout pass rather than inside the call, and the next one out works out where the control is
140
+ ## from where it is drawn, so asked in the same frame it scrolled to where the control had been.
141
+ ## Moved means a scroll value changed: a container asked to show what it already shows moves
142
+ ## nothing, and answering true for it said the view had moved when it had not. Only the containers in
143
+ ## the control's own viewport: one holding a dialog scrolls the page behind it, not the dialog.
144
+ static func scroll_into_view(control: Control, tree: SceneTree) -> bool:
145
+ var moved: bool = false
146
+ var walking: Variant = control.get_parent()
147
+ while walking != null and not (walking is Viewport):
148
+ var step: Node = walking
149
+ var holder: ScrollContainer = step as ScrollContainer
150
+ if holder != null:
151
+ var before: Vector2i = Vector2i(holder.scroll_horizontal, holder.scroll_vertical)
152
+ holder.ensure_control_visible(control)
153
+ if Vector2i(holder.scroll_horizontal, holder.scroll_vertical) != before:
154
+ moved = true
155
+ await tree.process_frame
156
+ # A panel that rebuilds itself can go in the frame this waited.
157
+ if not is_instance_valid(control) or not is_instance_valid(holder):
158
+ return moved
159
+ walking = step.get_parent()
160
+ return moved
161
+
162
+
163
+ ## Whether a pointer on its way out through [param out], the steps [method steps_out] answers, gets
164
+ ## past the interface in every viewport, as {"landed", "hovered"}: the path of the control that took
165
+ ## it, or of the one under it that let it by, or null. [param hover] answers what is hovered.
166
+ ##
167
+ ## In the viewport the node is drawn in, a control under the pointer takes it unless it and every
168
+ ## control holding it let the pointer pass. In each viewport outside that one, the pointer has to be
169
+ ## over what shows the one inside: the SubViewportContainer, with nothing drawn over it, or inside
170
+ ## the embedded window. Read in the first viewport alone, a click beside a container that is smaller
171
+ ## than what it shows, or under a panel drawn over the container, answered landed.
172
+ static func through_the_interface(out: Array[Dictionary], hover: GdhHover) -> Dictionary:
173
+ var first_under: Variant = null
174
+ for step: Dictionary in out:
175
+ var viewport: Viewport = step["viewport"]
176
+ var through: Variant = step["through"]
177
+ var hovered: Control = hover.hovered_in(viewport)
178
+ if through is Window:
179
+ var window: Window = through
180
+ var point: Vector2 = step["point"]
181
+ if not Rect2(window.position, window.size).has_point(point):
182
+ return {"landed": false, "hovered": _path_or_null(hovered)}
183
+ continue
184
+ if through is SubViewportContainer:
185
+ if hovered != through:
186
+ return {"landed": false, "hovered": _path_or_null(hovered)}
187
+ continue
188
+ if hovered != null and not _passes_on(hovered):
189
+ return {"landed": false, "hovered": str(hovered.get_path())}
190
+ if hovered != null and first_under == null:
191
+ first_under = str(hovered.get_path())
192
+ return {"landed": true, "hovered": first_under}
193
+
194
+
195
+ ## Whether a pointer over [param control] goes on past the interface: a control that lets it pass
196
+ ## hands it to the one holding it, and one that stops it takes it, up to a control drawn on its own
197
+ ## or something that is not a control. Measured on 4.7.2: a HUD over the room letting the pointer
198
+ ## pass answered not landed while the game's own input took the press. A script on a control that
199
+ ## lets it pass can still take the event for itself, which nothing outside it can see.
200
+ static func _passes_on(control: Control) -> bool:
201
+ var walk: Node = control
202
+ while walk is Control:
203
+ var at: Control = walk
204
+ if at.get_mouse_filter_with_override() == Control.MOUSE_FILTER_STOP:
205
+ return false
206
+ if at.top_level:
207
+ break
208
+ walk = at.get_parent()
209
+ return true
210
+
211
+
212
+ static func _path_or_null(node: Node) -> Variant:
213
+ if node == null:
214
+ return null
215
+ return str(node.get_path())
@@ -173,11 +173,12 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
173
173
 
174
174
  var under: String = "" if root_path == "/root" else " under %s" % root_path
175
175
  if targets.is_empty():
176
+ var from: Node = reached["node"]
176
177
  return {
177
178
  "type": "error",
178
179
  "message":
179
180
  (
180
- 'no control on screen%s says "%s", so nothing was clicked%s%s%s%s'
181
+ 'no control on screen%s says "%s", so nothing was clicked%s%s%s%s%s'
181
182
  % [
182
183
  under,
183
184
  wanted,
@@ -185,6 +186,7 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
185
186
  _outside_note(outside, root),
186
187
  _covered_note(covered, cover),
187
188
  _open_menu_note(root, wanted),
189
+ _offered_note(from, drawn, areas),
188
190
  ]
189
191
  )
190
192
  }
@@ -437,6 +439,18 @@ static func _passed_over(best: int, others: Array[int], one: bool) -> String:
437
439
 
438
440
 
439
441
  ## What to add to a refusal that found nothing on screen, when words matched controls out of sight.
442
+ static func _offered_note(from: Node, drawn: Array[Node], areas: Array[Rect2]) -> String:
443
+ var open: Callable = func(button: Control) -> bool:
444
+ var aim: Vector2 = button.size * 0.5
445
+ return (
446
+ GdhQueries.shown(button)
447
+ and not _off_screen(button, aim)
448
+ and _cover_of(button, drawn, areas, aim) == null
449
+ )
450
+ var words: String = GdhWords.pressable_words(from, open, LISTED_CANDIDATES)
451
+ return "" if words.is_empty() else "; the buttons on screen say %s" % words
452
+
453
+
440
454
  static func _hidden_note(hidden: int) -> String:
441
455
  if hidden == 0:
442
456
  return ""
@@ -227,6 +227,8 @@ func _collect(id: int, timeout_ms: int) -> Dictionary:
227
227
  "args": _values.serialize(watch.catcher.args),
228
228
  "elapsed_ms": Time.get_ticks_msec() - started,
229
229
  }
230
+ if watch.catcher.fired:
231
+ answer["fired_after_ms"] = watch.catcher.fired_after_ms
230
232
  if before:
231
233
  answer["fired_before_wait"] = true
232
234
  return answer
@@ -848,10 +850,16 @@ class GdhSignalCatcher:
848
850
  ## How many times it fired; [member args] holds what the first carried.
849
851
  var times: int = 0
850
852
  var args: Array = []
853
+ ## Milliseconds from when this began listening to the first emission, -1 before one. A wait
854
+ ## collecting a watch measured only itself, so a call made between the arming and the wait left
855
+ ## nothing saying how long after it the signal came.
856
+ var fired_after_ms: int = -1
857
+ var _armed_at: int = Time.get_ticks_msec()
851
858
 
852
859
  func _on_fired(...carried: Array) -> void:
853
860
  if not fired:
854
861
  args = carried
862
+ fired_after_ms = Time.get_ticks_msec() - _armed_at
855
863
  fired = true
856
864
  times += 1
857
865
 
@@ -144,6 +144,28 @@ static func draws_a_list(node: Node) -> bool:
144
144
  return node is TabBar or node is MenuBar or node is ItemList or node is PopupMenu or node is Tree
145
145
 
146
146
 
147
+ ## The words of the enabled buttons under [param from], quoted, each once and in the order they are
148
+ ## drawn, that [param open] says a player could press: at most [param most], then "and more". What a
149
+ ## refusal of words nothing on screen says offers instead: a click for "Settings" named nothing,
150
+ ## though Play, Options and Quit were all there.
151
+ static func pressable_words(from: Node, open: Callable, most: int) -> String:
152
+ var offered: Array[String] = []
153
+ var pending: Array[Node] = [from]
154
+ while not pending.is_empty():
155
+ var node: Node = pending.pop_front()
156
+ var button: BaseButton = node as BaseButton
157
+ var words: String = "" if button == null or button.disabled else said_by(button).strip_edges()
158
+ var quoted: String = '"%s"' % words
159
+ if not words.is_empty() and not offered.has(quoted) and open.call(button):
160
+ if offered.size() == most:
161
+ return ", ".join(offered) + ", and more"
162
+ offered.append(quoted)
163
+ var children: Array[Node] = node.get_children()
164
+ for index: int in range(children.size() - 1, -1, -1):
165
+ pending.push_front(children[index])
166
+ return ", ".join(offered)
167
+
168
+
147
169
  static func _keep(line: String, into: Array[String]) -> void:
148
170
  var trimmed: String = line.strip_edges()
149
171
  if not trimmed.is_empty():
@@ -131,6 +131,22 @@ static func _summary(anim: Animation) -> Dictionary:
131
131
  }
132
132
 
133
133
 
134
+ ## [param seconds], a key's time, as the shortest decimal holding the same 32-bit float. A scene keeps
135
+ ## key times as 32-bit floats, so a key put at 0.7 read back as 0.699999988079071.
136
+ static func key_time(seconds: float) -> float:
137
+ var held: float = PackedFloat32Array([seconds])[0]
138
+ for places: int in range(0, 10):
139
+ var shorter: float = String.num(held, places).to_float()
140
+ if PackedFloat32Array([shorter])[0] == held:
141
+ return shorter
142
+ return held
143
+
144
+
145
+ ## Whether two times are one key's: the same 32-bit float, as a scene stores them.
146
+ static func same_time(first: float, second: float) -> bool:
147
+ return PackedFloat32Array([first])[0] == PackedFloat32Array([second])[0]
148
+
149
+
134
150
  static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dictionary:
135
151
  var kind: int = anim.track_get_type(track)
136
152
  var path: NodePath = anim.track_get_path(track)
@@ -143,7 +159,7 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
143
159
  var keyframes: Array[Dictionary] = []
144
160
  var methods: Dictionary = {}
145
161
  for key: int in anim.track_get_key_count(track):
146
- var keyframe: Dictionary = {"time": anim.track_get_key_time(track, key)}
162
+ var keyframe: Dictionary = {"time": key_time(anim.track_get_key_time(track, key))}
147
163
  if kind == Animation.TYPE_METHOD:
148
164
  var method: String = str(anim.method_track_get_name(track, key))
149
165
  methods[method] = true
@@ -310,6 +310,10 @@ func _not_found(wanted: String, declared: Dictionary, answers_own: bool = false)
310
310
  for name: String in close:
311
311
  if name.to_lower().similarity(wanted.to_lower()) < best - CLOSE_TO_THE_BEST:
312
312
  continue
313
+ # Similarity counts shared letter pairs, so a word the two names share passes it: NoSuchClass
314
+ # was offered ClassDB. A spelling is a few letters off, a swap of two among them.
315
+ if _edits(name.to_lower(), wanted.to_lower()) > maxi(2, floori(wanted.length() / 3.0)):
316
+ continue
313
317
  if declared.has(name):
314
318
  own.append(name)
315
319
  else:
@@ -347,6 +351,18 @@ func _not_found(wanted: String, declared: Dictionary, answers_own: bool = false)
347
351
  )
348
352
 
349
353
 
354
+ ## How many letters must be put in, taken out or changed to make [param from] into [param to].
355
+ static func _edits(from: String, to: String) -> int:
356
+ var above: PackedInt32Array = PackedInt32Array(range(to.length() + 1))
357
+ for row: int in range(1, from.length() + 1):
358
+ var here: PackedInt32Array = PackedInt32Array([row])
359
+ for column: int in range(1, to.length() + 1):
360
+ var changed: int = above[column - 1] + (0 if from[row - 1] == to[column - 1] else 1)
361
+ var _added: bool = here.append(mini(changed, mini(above[column] + 1, here[column - 1] + 1)))
362
+ above = here
363
+ return above[to.length()]
364
+
365
+
350
366
  # One member of a class by name, wherever in its ancestry it is declared.
351
367
  #
352
368
  # The question a member is asked by name for is "does this engine have it, and what does it take":
@@ -123,12 +123,18 @@ func get_dependencies(params: Dictionary) -> Dictionary:
123
123
 
124
124
 
125
125
  ## Every file a scene or resource of the project loads through an ext_resource that is not there,
126
- ## by its path or its uid, each with the files loading it. Only those: a script naming a path in
127
- ## its code may be asking whether the file exists, and the shipped addons check for files they
128
- ## write, so every project was told its addons referred to files that are missing. A script's
129
- ## preloads are the compile's to judge.
126
+ ## by its path or its uid, and every one a script hands load() as written, each with the files
127
+ ## loading it. Not any path a script names: one may be asking whether the file exists, and the
128
+ ## shipped addons check for files they write, so every project was told its addons referred to
129
+ ## files that are missing. A load() of a file the script also asks exists() about is left out for
130
+ ## the same reason, and a script's preloads are the compile's to judge.
130
131
  func missing_references() -> Array[Dictionary]:
131
132
  var missing: Dictionary = {}
133
+ for file: String in _files.find_files("res://", ".gd"):
134
+ for path: String in _loaded_and_missing(file):
135
+ var naming: Array = missing.get_or_add(path, [])
136
+ if not file in naming:
137
+ naming.append(file)
132
138
  for file: String in _files.find_files_with_extensions("res://", GdhResourceFiles.TEXT_RESOURCE_KINDS):
133
139
  var content: String = FileAccess.get_file_as_string(file)
134
140
  var moved: Dictionary = _moved_by_uid(content)
@@ -158,6 +164,42 @@ func missing_references() -> Array[Dictionary]:
158
164
  return found
159
165
 
160
166
 
167
+ ## The files [param script] loads with load() or ResourceLoader.load() by a path written in the call
168
+ ## that are not on disk. A load called on another object, such as a ConfigFile's, reads a file that
169
+ ## object's owner may write, so only the bare call and the ResourceLoader's count. A path the script
170
+ ## names anywhere else, asking whether it exists, writing it or removing it, is one it looks after
171
+ ## itself, and a `load(` inside a string is not a call: read across lines, one in a string literal
172
+ ## took the rest of the file up to the next quote as a path.
173
+ func _loaded_and_missing(script: String) -> Array[String]:
174
+ var loaded: Array[String] = []
175
+ var named: Dictionary = {}
176
+ for line: String in FileAccess.get_file_as_string(script).split("\n"):
177
+ var code: String = GdhPatterns.without_comment(line)
178
+ if not code.contains("load("):
179
+ for literal: String in _string_literals(code):
180
+ named[_resolved(literal, script)] = true
181
+ continue
182
+ var calls: Dictionary = {}
183
+ for loading: RegExMatch in (
184
+ GdhPatterns.compiled("(?:(?<![\\w.])|ResourceLoader\\.)load\\(\\s*(?=[\"'])").search_all(code)
185
+ ):
186
+ calls[loading.get_end()] = true
187
+ for literal: RegExMatch in GdhPatterns.compiled(STRING_LITERAL).search_all(code):
188
+ var single: String = literal.get_string(2)
189
+ var path: String = _resolved(literal.get_string(1) if single.is_empty() else single, script)
190
+ if calls.has(literal.get_start()):
191
+ loaded.append(path)
192
+ else:
193
+ named[path] = true
194
+ var missing: Array[String] = []
195
+ for path: String in loaded:
196
+ if not path.begins_with("res://") or path.begins_with("res://.") or path.get_extension().is_empty():
197
+ continue
198
+ if not named.has(path) and not FileAccess.file_exists(path) and not missing.has(path):
199
+ missing.append(path)
200
+ return missing
201
+
202
+
161
203
  ## The files project.godot's settings name that are not on disk: an autoload, a registered
162
204
  ## translation, an icon, an enabled plugin. Not the main scene, which has a check of its own, and
163
205
  ## the engine reports each of the rest on every boot.
@@ -86,11 +86,11 @@ static func run(params: Dictionary) -> Dictionary:
86
86
  if not GdhSceneUids.by_path.is_empty():
87
87
  answered["namedByPath"] = (
88
88
  (
89
- "What this saved names %s by path rather than by uid. A uid in a file's own header is "
90
- + "resolved through the project's uid cache, which holds none for %s yet, as with a "
91
- + "file made since the project was last scanned, and a uid it does not hold would "
92
- + "warn on every load. project_import refresh_uids scans the project, and writes "
93
- + "after it name %s by uid."
89
+ "What this saved names %s by path rather than by uid, which loads the same. A uid in a "
90
+ + "file's own header is resolved through the project's uid cache, which holds none for "
91
+ + "%s yet, as with a file made since the project was last scanned, so a uid written "
92
+ + "now would warn on every load. project_import refresh_uids scans the project, and "
93
+ + "writes after it name %s by uid."
94
94
  )
95
95
  % [
96
96
  ", ".join(GdhSceneUids.by_path),
@@ -116,7 +116,10 @@ func create_gdscript(params: Dictionary) -> Dictionary:
116
116
  # answered for a file whose header says otherwise, so those are read off the text instead.
117
117
  if parses:
118
118
  answer["extends"] = _base_named(written)
119
- answer["class_name"] = str(written.get_global_name())
119
+ var declared: String = str(written.get_global_name())
120
+ answer["class_name"] = null
121
+ if not declared.is_empty():
122
+ answer["class_name"] = declared
120
123
  else:
121
124
  answer.merge(_header_written(script_content))
122
125
  return answer
@@ -134,9 +137,10 @@ static func _not_gdscript(path: String, doing: String) -> String:
134
137
 
135
138
 
136
139
  ## The class_name and extends [param text] declares at its margin, as written. A script with no
137
- ## extends extends RefCounted.
140
+ ## extends extends RefCounted, and one with no class_name answers null for it, as an absent name
141
+ ## does everywhere else rather than as a name that is empty.
138
142
  static func _header_written(text: String) -> Dictionary:
139
- var header: Dictionary = {"extends": "RefCounted", "class_name": ""}
143
+ var header: Dictionary = {"extends": "RefCounted", "class_name": null}
140
144
  for line: String in text.split("\n"):
141
145
  # An inner class's extends is indented, and is not the script's.
142
146
  if line.begins_with(" ") or line.begins_with("\t"):