gdharness 1.2.30 → 1.2.32

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 (37) hide show
  1. package/build/cli.js +7197 -6159
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +14 -0
  3. package/build/godot/addons/gdharness_editor/file_reads.gd +237 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd.uid +1 -0
  5. package/build/godot/addons/gdharness_editor/plugin.gd +27 -0
  6. package/build/godot/addons/gdharness_editor/property_values.gd +80 -54
  7. package/build/godot/addons/gdharness_editor/scene_file.gd +38 -1
  8. package/build/godot/addons/gdharness_editor/serialisation.gd +46 -0
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +1 -0
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +22 -0
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +64 -1
  12. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +82 -44
  13. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +178 -0
  14. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +73 -124
  16. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +39 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_hover.gd +285 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_hover.gd.uid +1 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_input.gd +151 -106
  20. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +112 -0
  21. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd.uid +1 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +5 -4
  23. package/build/godot/addons/gdharness_runtime/runtime_values.gd +1 -47
  24. package/build/godot/addons/gdharness_runtime/serialisation.gd +46 -0
  25. package/build/godot/operations/audio_buses.gd +14 -1
  26. package/build/godot/operations/class_cache.gd +146 -35
  27. package/build/godot/operations/classdb_queries.gd +19 -12
  28. package/build/godot/operations/file_reads.gd +237 -0
  29. package/build/godot/operations/gdscript_authoring.gd +136 -19
  30. package/build/godot/operations/godot_operations.gd +24 -0
  31. package/build/godot/operations/project_config.gd +16 -6
  32. package/build/godot/operations/property_values.gd +767 -0
  33. package/build/godot/operations/rename_checks.gd +5 -2
  34. package/build/godot/operations/serialisation.gd +46 -0
  35. package/build/index.js +4896 -3872
  36. package/build/keeper.js +184 -71
  37. package/package.json +1 -1
@@ -83,7 +83,20 @@ func get_audio_buses(_params: Dictionary) -> Dictionary:
83
83
  var buses: Array[Dictionary] = []
84
84
  for i: int in range(AudioServer.bus_count):
85
85
  buses.append(_bus(i))
86
- return {"bus_count": AudioServer.bus_count, "buses": buses, "layout": _layout_path()}
86
+ var layout: String = _layout_path()
87
+ var answer: Dictionary = {"bus_count": AudioServer.bus_count, "buses": buses, "layout": layout}
88
+ # The setting names a path whether or not anything is there, and a project that never changed a
89
+ # bus has no file: the path alone read as a layout the project keeps.
90
+ if not FileAccess.file_exists(layout):
91
+ answer["layout_on_disk"] = false
92
+ answer["note"] = (
93
+ (
94
+ "Nothing is at %s, so these are the engine's default buses; the first change to a bus "
95
+ + "writes the layout there."
96
+ )
97
+ % layout
98
+ )
99
+ return answer
87
100
 
88
101
 
89
102
  func set_audio_bus_effect(params: Dictionary) -> Dictionary:
@@ -93,13 +93,13 @@ func _entry_for(path: String, skipped: Array[Dictionary]) -> Dictionary:
93
93
  if declared.is_empty():
94
94
  return {}
95
95
 
96
- var base: String = _base_of(path, header, skipped)
97
- if base.is_empty():
96
+ var base: Variant = _base_of(path, header, skipped)
97
+ if base == null:
98
98
  return {}
99
99
 
100
100
  # The field order is the editor's, so a rebuilt file reads as one the editor wrote.
101
101
  return {
102
- "base": StringName(base),
102
+ "base": StringName(str(base)),
103
103
  "class": StringName(declared),
104
104
  "icon": str(header.get("icon", "")),
105
105
  "is_abstract": Read.as_bool(header.get("abstract", false)),
@@ -109,46 +109,157 @@ func _entry_for(path: String, skipped: Array[Dictionary]) -> Dictionary:
109
109
  }
110
110
 
111
111
 
112
- # What the editor records as the base: the nearest ancestor with a class_name, else the native
113
- # class at the top of the chain. A script that extends by path is followed to its own header.
114
- func _base_of(path: String, header: Dictionary, skipped: Array[Dictionary]) -> String:
115
- var visited: Array[String] = [path]
116
- var current: Dictionary = header
112
+ # What the editor records as the base, worked out as the engine's own class scan works it out,
113
+ # measured on 4.7.2 against `--import`, so a rebuilt list is the one the editor would write and a
114
+ # rebuild over a current list changes nothing:
115
+ #
116
+ # - nothing extended: RefCounted;
117
+ # - one name, a global class or a native one: that name, unresolved;
118
+ # - a dotted name, `Heard.Named`, an inner class of a global class: nothing, as the scan does not
119
+ # look names up;
120
+ # - a path: what that script's class records, after walking into the inner classes named after
121
+ # the path, `"res://heard.gd".Named`, so a parent with a class_name is passed over for its own
122
+ # base. A path the engine cannot open records nothing, and it opens a relative one from the
123
+ # project root rather than from the script's folder.
124
+ #
125
+ # Null for a script that extends itself, which is left out of the list and named in [param skipped].
126
+ func _base_of(path: String, header: Dictionary, skipped: Array[Dictionary]) -> Variant:
127
+ var visited: Array[String] = [path + ":"]
128
+ var extends_what: Variant = header.get("extends", null)
117
129
  while true:
118
- var extends_what: String = str(current.get("extends", ""))
119
- if extends_what.is_empty():
130
+ if extends_what == null:
120
131
  return "RefCounted"
121
- if not (extends_what.begins_with('"') or extends_what.begins_with("'")):
122
- return extends_what
123
- var parent_path: String = extends_what.substr(1, extends_what.length() - 2)
124
- if not parent_path.begins_with("res://"):
125
- parent_path = visited[visited.size() - 1].get_base_dir().path_join(parent_path)
126
- if not FileAccess.file_exists(parent_path):
127
- skipped.append({"path": path, "reason": "extends a script that does not exist: " + parent_path})
132
+ var parts: Dictionary = _extends_parts(str(extends_what))
133
+ var parent_path: String = parts["path"]
134
+ var names: Array[String] = parts["names"]
135
+ if parent_path.is_empty():
136
+ return names[0] if names.size() == 1 else ""
137
+ var opened: String = _as_the_engine_opens(parent_path)
138
+ if not FileAccess.file_exists(opened):
128
139
  return ""
129
- if parent_path in visited:
140
+ var reached: String = opened + ":" + ".".join(PackedStringArray(names))
141
+ if reached in visited:
130
142
  skipped.append({"path": path, "reason": "extends itself through " + parent_path})
143
+ return null
144
+ visited.append(reached)
145
+ var lines: PackedStringArray = FileAccess.get_file_as_string(opened).split("\n")
146
+ if names.is_empty():
147
+ extends_what = _header_in(lines).get("extends", null)
148
+ continue
149
+ var inner: Dictionary = _inner_class(lines, names)
150
+ if inner.is_empty():
131
151
  return ""
132
- visited.append(parent_path)
133
- current = _header_of(parent_path)
134
- var parent_name: String = str(current.get("class_name", ""))
135
- if not parent_name.is_empty():
136
- return parent_name
152
+ extends_what = inner.get("extends", null)
137
153
  return ""
138
154
 
139
155
 
140
- # The declarations at the top of a script: class_name, what it extends (a class name, or a
141
- # quoted path), and the @tool, @abstract and @icon annotations. Reading stops at the first
142
- # statement that is none of those, which is where the body begins.
156
+ # The path and the class names an `extends` names, from the text after the keyword:
157
+ # `"res://heard.gd".Named` is the path and ["Named"], `Heard.Named` no path and two names. The
158
+ # reading stops at whatever follows, a colon or a comment.
159
+ static func _extends_parts(text: String) -> Dictionary:
160
+ var rest: String = text.strip_edges()
161
+ var path: String = ""
162
+ if rest.begins_with('"') or rest.begins_with("'"):
163
+ var closing: int = rest.find(rest[0], 1)
164
+ if closing == -1:
165
+ return {"path": "", "names": [] as Array[String]}
166
+ path = rest.substr(1, closing - 1)
167
+ rest = rest.substr(closing + 1)
168
+ var names: Array[String] = []
169
+ var name_at: RegEx = Patterns.compiled("^([A-Za-z_][A-Za-z0-9_]*)")
170
+ var dotted: bool = not path.is_empty()
171
+ while true:
172
+ if dotted:
173
+ if not rest.begins_with("."):
174
+ break
175
+ rest = rest.substr(1)
176
+ dotted = true
177
+ var found: RegExMatch = name_at.search(rest)
178
+ if found == null:
179
+ break
180
+ names.append(found.get_string(1))
181
+ rest = rest.substr(found.get_end())
182
+ return {"path": path, "names": names}
183
+
184
+
185
+ # The file the engine's class scan reads for an `extends` path. It opens the path as written, and
186
+ # the engine runs from the project root, so a relative path is read from there.
187
+ static func _as_the_engine_opens(path: String) -> String:
188
+ if path.is_absolute_path():
189
+ return path
190
+ var absolute: String = ProjectSettings.globalize_path("res://").path_join(path).simplify_path()
191
+ return ProjectSettings.localize_path(absolute)
192
+
193
+
194
+ # The inner class [param names] reaches in a script of [param lines], one class inside the
195
+ # previous, as {"found": true, "extends": the text after its keyword} with no "extends" for one that
196
+ # extends nothing, or {} when one of them is not there.
197
+ func _inner_class(lines: PackedStringArray, names: Array[String]) -> Dictionary:
198
+ var class_line: RegEx = Patterns.compiled("^class\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(.*)$")
199
+ var inline_extends: RegEx = Patterns.compiled("^extends\\s+(.*)$")
200
+ var start: int = 0
201
+ var end: int = lines.size()
202
+ var depth: int = 0
203
+ var found: Dictionary = {}
204
+ for name: String in names:
205
+ var at: int = -1
206
+ found = {"found": true}
207
+ for index: int in range(start, end):
208
+ var text: String = lines[index].strip_edges()
209
+ if text.is_empty() or text.begins_with("#") or _indent(lines[index]) != depth:
210
+ continue
211
+ var declared: RegExMatch = class_line.search(text)
212
+ if declared == null or declared.get_string(1) != name:
213
+ continue
214
+ at = index
215
+ var inline: RegExMatch = inline_extends.search(declared.get_string(2).strip_edges())
216
+ if inline != null:
217
+ found["extends"] = inline.get_string(1)
218
+ break
219
+ if at == -1:
220
+ return {}
221
+ start = at + 1
222
+ var body_end: int = start
223
+ var body_depth: int = -1
224
+ for index: int in range(start, end):
225
+ var text: String = lines[index].strip_edges()
226
+ if not text.is_empty() and not text.begins_with("#"):
227
+ var width: int = _indent(lines[index])
228
+ if width <= depth:
229
+ break
230
+ if body_depth == -1:
231
+ body_depth = width
232
+ body_end = index + 1
233
+ end = body_end
234
+ depth = body_depth
235
+ if not found.has("extends"):
236
+ var body: Dictionary = _header_in(lines.slice(start, end))
237
+ if body.has("extends"):
238
+ found["extends"] = body["extends"]
239
+ return found
240
+
241
+
242
+ static func _indent(line: String) -> int:
243
+ return line.length() - line.lstrip(" \t").length()
244
+
245
+
143
246
  func _header_of(path: String) -> Dictionary:
247
+ if not FileAccess.file_exists(path):
248
+ return {}
249
+ return _header_in(FileAccess.get_file_as_string(path).split("\n"))
250
+
251
+
252
+ # The declarations at the top of a script: class_name, what it extends (the text after the
253
+ # keyword, a class name or a quoted path, either followed by inner class names), and the @tool,
254
+ # @abstract and @icon annotations. Reading stops at the first statement that is none of those,
255
+ # which is where the body begins.
256
+ func _header_in(lines: PackedStringArray) -> Dictionary:
144
257
  var header: Dictionary = {}
145
- var file: FileAccess = FileAccess.open(path, FileAccess.READ)
146
- if not file:
147
- return header
148
258
  var annotation: RegEx = Patterns.compiled('^@([a-z_]+)(?:\\(\\s*(?:"([^"]*)")?[^)]*\\))?\\s*')
149
- var extends_line: RegEx = Patterns.compiled("^extends\\s+(\\S+)")
150
- while not file.eof_reached():
151
- var rest: String = file.get_line().strip_edges()
259
+ var extends_line: RegEx = Patterns.compiled("^extends\\s+(.*)$")
260
+ var same_line: RegEx = Patterns.compiled("^\\s+extends\\s+(.*)$")
261
+ for line: String in lines:
262
+ var rest: String = line.strip_edges()
152
263
  # Annotations may share a line with what they annotate, as in `@abstract class_name X`.
153
264
  var annotated: RegExMatch = annotation.search(rest)
154
265
  while annotated != null:
@@ -167,15 +278,15 @@ func _header_of(path: String) -> Dictionary:
167
278
  var declared: RegExMatch = Patterns.declared_class(rest)
168
279
  if declared != null:
169
280
  header["class_name"] = declared.get_string(1)
170
- if not declared.get_string(2).is_empty():
171
- header["extends"] = declared.get_string(2)
281
+ var after: RegExMatch = same_line.search(rest.substr(declared.get_end(1)))
282
+ if after != null:
283
+ header["extends"] = after.get_string(1)
172
284
  elif rest.begins_with("extends"):
173
285
  var parent: RegExMatch = extends_line.search(rest)
174
286
  if parent != null:
175
287
  header["extends"] = parent.get_string(1)
176
288
  else:
177
289
  break
178
- file.close()
179
290
  return header
180
291
 
181
292
 
@@ -2,6 +2,7 @@ extends RefCounted
2
2
 
3
3
  const Read = preload("reading.gd")
4
4
  const Log = preload("logger.gd")
5
+ const Serialisation = preload("serialisation.gd")
5
6
 
6
7
  # The base classes each category name stands for. Kept to the list the tool's schema offers, which
7
8
  # a case holds: the schema offered physics3d and ui, which this refused, and physics answered the 3D
@@ -22,6 +23,7 @@ const CATEGORY_BASES: Dictionary = {
22
23
  }
23
24
 
24
25
  var _log: Log
26
+ var _values: Serialisation = Serialisation.new()
25
27
 
26
28
 
27
29
  func _init(p_log: Log) -> void:
@@ -318,19 +320,24 @@ func _methods_of(class_name_str: String, include_inherited: bool) -> Array[Dicti
318
320
  }
319
321
  )
320
322
  var return_info: Dictionary = m.get("return", {})
321
- methods.append(
322
- {
323
- "name": m.get("name", ""),
324
- "args": args,
325
- "return":
323
+ (
324
+ methods
325
+ . append(
326
326
  {
327
- "type": return_info.get("type", 0),
328
- "type_name": _type_name(return_info, "void"),
329
- "class_name": return_info.get("class_name", "")
330
- },
331
- "flags": m.get("flags", 0),
332
- "default_args": m.get("default_args", [])
333
- }
327
+ "name": m.get("name", ""),
328
+ "args": args,
329
+ "return":
330
+ {
331
+ "type": return_info.get("type", 0),
332
+ "type_name": _type_name(return_info, "void"),
333
+ "class_name": return_info.get("class_name", "")
334
+ },
335
+ "flags": m.get("flags", 0),
336
+ # As values, tagged the way every other answer tags one: JSON writes a Vector2i it is
337
+ # handed as its text, so TileMapLayer.set_cell's default atlas coords read "(-1, -1)".
338
+ "default_args": _values.serialize_value(m.get("default_args", []))
339
+ }
340
+ )
334
341
  )
335
342
 
336
343
  return methods
@@ -0,0 +1,237 @@
1
+ extends RefCounted
2
+
3
+ ## Scenes and resources read from their files, by the editor's tools and by a headless engine with
4
+ ## none connected, so a read answers the same either way.
5
+ ##
6
+ ## A scene is read off its SceneState rather than instantiated. Instantiating outside the editor
7
+ ## runs every script's member initialisers and _init (measured on 4.7.2, with or without -e), so a
8
+ ## read with no editor would have run the game's code on every node; the editor gets placeholders
9
+ ## and runs none. The state holds everything the file says, and the scenes it instances theirs,
10
+ ## without making a node. Loading the file still does what loading does in the game: static
11
+ ## initialisers run, and a resource with a script is made with it.
12
+
13
+ const PropertyValues = preload("property_values.gd")
14
+ const Read = preload("reading.gd")
15
+ const Serialisation = preload("serialisation.gd")
16
+
17
+
18
+ ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf on a node that
19
+ ## instances another scene and every stored property when [param include_properties] is set, the
20
+ ## file's value or the default where it sets none. [param depth] -1 reads every level.
21
+ static func scene_tree(scene_path: String, depth: int, include_properties: bool) -> Dictionary:
22
+ if not FileAccess.file_exists(scene_path):
23
+ return {"ok": false, "error": "Scene not found: " + scene_path}
24
+ # Loaded without naming the type, so a resource handed in by mistake loads as what it is and the
25
+ # refusal can say so, where asking for a PackedScene printed the loader's error and a backtrace.
26
+ var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
27
+ if loaded == null:
28
+ return {"ok": false, "error": "Failed to load %s as a scene" % scene_path}
29
+ if not loaded is PackedScene:
30
+ return {
31
+ "ok": false,
32
+ "error":
33
+ (
34
+ "%s is a %s, not a scene: resource_edit read reads its properties."
35
+ % [scene_path, loaded.get_class()]
36
+ )
37
+ }
38
+ var packed: PackedScene = loaded
39
+ var nodes: Dictionary = {}
40
+ var refused: String = _lay(packed.get_state(), ".", nodes, [scene_path])
41
+ if not refused.is_empty():
42
+ return {"ok": false, "error": refused}
43
+ if not nodes.has("."):
44
+ return {"ok": false, "error": "%s holds no nodes" % scene_path}
45
+ return {"ok": true, "tree": _entry(nodes, ".", include_properties, depth, 0)}
46
+
47
+
48
+ ## Lays the nodes of [param state] into [param nodes] under [param prefix], keyed by their path
49
+ ## from the outermost root, each instanced scene laid where it is instanced and the instancing
50
+ ## file's own entries for it applied over it. Answers a refusal, or "".
51
+ ##
52
+ ## [param within] is the files being laid, outermost first, so a scene that instances one of them
53
+ ## is refused rather than laid forever; the engine refuses to load such a scene, so this is only
54
+ ## reached through a state built some other way.
55
+ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: Array) -> String:
56
+ var base: SceneState = state.get_base_scene_state()
57
+ if base != null and not nodes.has(prefix):
58
+ var refused: String = _lay(base, prefix, nodes, within)
59
+ if not refused.is_empty():
60
+ return refused
61
+ for index: int in state.get_node_count():
62
+ var path: String = _joined(prefix, str(state.get_node_path(index)))
63
+ var fresh: bool = not nodes.has(path)
64
+ if fresh:
65
+ var instance: PackedScene = state.get_node_instance(index)
66
+ if instance != null:
67
+ if within.has(instance.resource_path):
68
+ return "%s instances %s, which holds it" % [within.back(), instance.resource_path]
69
+ var refused: String = _lay(
70
+ instance.get_state(), path, nodes, within + [instance.resource_path]
71
+ )
72
+ if not refused.is_empty():
73
+ return refused
74
+ if not nodes.has(path):
75
+ return "%s holds no nodes" % instance.resource_path
76
+ nodes[path]["instanceOf"] = instance.resource_path
77
+ elif state.is_node_instance_placeholder(index):
78
+ nodes[path] = _made("InstancePlaceholder")
79
+ nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
80
+ elif str(state.get_node_type(index)).is_empty():
81
+ # An entry for a node of an instance that no longer has it, which the engine skips too.
82
+ continue
83
+ else:
84
+ nodes[path] = _made(str(state.get_node_type(index)))
85
+ # The name is the state's even for an entry laid already, since an inherited scene names its
86
+ # root itself; a root instanced inside another scene is renamed by that scene once laid.
87
+ nodes[path]["name"] = str(state.get_node_name(index))
88
+ if fresh:
89
+ # The root of what is laid here is placed by whoever instanced it.
90
+ if path != prefix:
91
+ _place(
92
+ nodes,
93
+ _joined(prefix, str(state.get_node_path(index, true))),
94
+ path,
95
+ state.get_node_index(index)
96
+ )
97
+ var held: Dictionary = nodes[path]["properties"]
98
+ for property: int in state.get_node_property_count(index):
99
+ held[str(state.get_node_property_name(index, property))] = state.get_node_property_value(
100
+ index, property
101
+ )
102
+ return ""
103
+
104
+
105
+ static func _made(type: String) -> Dictionary:
106
+ var children: Array[String] = []
107
+ return {"name": "", "type": type, "properties": {}, "children": children}
108
+
109
+
110
+ ## Puts [param path] among [param parent]'s children, at [param at] when the file says where (a node
111
+ ## added inside an instance) and last when it does not.
112
+ static func _place(nodes: Dictionary, parent: String, path: String, at: int) -> void:
113
+ if not nodes.has(parent):
114
+ return
115
+ var children: Array[String] = nodes[parent]["children"]
116
+ if at >= 0 and at < children.size():
117
+ var _placed: int = children.insert(at, path)
118
+ else:
119
+ children.append(path)
120
+
121
+
122
+ ## [param given], a path as a SceneState gives it ("." or "./Body/Tail"), under [param prefix].
123
+ static func _joined(prefix: String, given: String) -> String:
124
+ var relative: String = given.trim_prefix("./")
125
+ if relative == "." or relative.is_empty():
126
+ return prefix
127
+ if prefix == ".":
128
+ return relative
129
+ return prefix + "/" + relative
130
+
131
+
132
+ static func _entry(
133
+ nodes: Dictionary, path: String, include_properties: bool, depth: int, current_depth: int
134
+ ) -> Dictionary:
135
+ var node: Dictionary = nodes[path]
136
+ var children: Array[Dictionary] = []
137
+ var data: Dictionary = {"name": node["name"], "type": node["type"], "path": path, "children": children}
138
+ # The root's file is the scene being read, so only another node's names a second scene.
139
+ if path != "." and node.has("instanceOf"):
140
+ data["instanceOf"] = node["instanceOf"]
141
+ if include_properties:
142
+ var given: Dictionary = node["properties"]
143
+ data["properties"] = _stored(str(node["type"]), given)
144
+ if depth >= 0 and current_depth >= depth:
145
+ return data
146
+ for child: String in node["children"]:
147
+ children.append(_entry(nodes, child, include_properties, depth, current_depth + 1))
148
+ return data
149
+
150
+
151
+ ## Every property a node of [param type] stores, with the value [param given] sets or the default,
152
+ ## read as a node holding those values would answer them.
153
+ ##
154
+ ## The engine's half is read off a bare node of the class with the file's values set on it, which
155
+ ## runs no script: what a node lists depends on its values (a thread group's settings appear only
156
+ ## once it has one) and some setters set others (a Control's anchors preset sets how it grows), so
157
+ ## the class's defaults and the file's entries alone answered neither. The script's half is its
158
+ ## declared defaults under the file's values.
159
+ static func _stored(type: String, given: Dictionary) -> Dictionary:
160
+ var attached: Variant = given.get("script")
161
+ var script: Script = attached if attached is Script else null
162
+ var scripted: Dictionary = {}
163
+ if script != null:
164
+ for declared: Dictionary in script.get_script_property_list():
165
+ var property: String = str(declared.get("name", ""))
166
+ if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
167
+ scripted[property] = script.get_property_default_value(property)
168
+ var values: Dictionary = {}
169
+ if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
170
+ var bare: Object = ClassDB.instantiate(type)
171
+ for property: Variant in given:
172
+ if property != "script" and not scripted.has(property):
173
+ bare.set(str(property), given[property])
174
+ for declared: Dictionary in bare.get_property_list():
175
+ var property: String = str(declared.get("name", ""))
176
+ if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
177
+ values[property] = bare.get(property)
178
+ if bare is Node:
179
+ bare.free()
180
+ for property: String in scripted:
181
+ values[property] = scripted[property]
182
+ # Given last, which keeps what a bare node cannot hold: the script itself, and a path to a node
183
+ # for a property that holds the node once it is in a tree.
184
+ values.merge(given, true)
185
+ var serialiser: Serialisation = Serialisation.new()
186
+ var answered: Dictionary = {}
187
+ for property: Variant in values:
188
+ answered[property] = serialiser.serialize_value(values[property])
189
+ return answered
190
+
191
+
192
+ ## What a resource file holds, in the form modify takes, so a value read here can be written back.
193
+ ## Read from the file rather than a cached copy, since the file is what the writes change and what
194
+ ## the project loads.
195
+ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
196
+ if not FileAccess.file_exists(res_path):
197
+ return {"ok": false, "error": "No resource at " + res_path}
198
+ var loaded: Resource = ResourceLoader.load(res_path, "", ResourceLoader.CACHE_MODE_IGNORE)
199
+ if loaded == null:
200
+ return {"ok": false, "error": "%s did not load as a resource." % res_path}
201
+ # A scene's own properties are its packed bundle, which says nothing a caller can use.
202
+ if loaded is PackedScene:
203
+ return {
204
+ "ok": false,
205
+ "error":
206
+ "%s is a scene: scene_tree reads its nodes, and scene_node get one node's properties." % res_path
207
+ }
208
+
209
+ var script: Script = loaded.get_script()
210
+ var scripted: Dictionary = {}
211
+ if script != null:
212
+ for declared: Dictionary in script.get_script_property_list():
213
+ scripted[str(declared.get("name", ""))] = true
214
+ var serialiser: Serialisation = Serialisation.new()
215
+ var properties: Dictionary = {}
216
+ for p: Dictionary in loaded.get_property_list():
217
+ var property: String = str(p.get("name", ""))
218
+ if property.is_empty() or property == "script":
219
+ continue
220
+ if not (Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
221
+ continue
222
+ var value: Variant = loaded.get(property)
223
+ if not include_defaults:
224
+ var default: Variant = (
225
+ script.get_property_default_value(property)
226
+ if scripted.has(property)
227
+ else ClassDB.class_get_property_default_value(loaded.get_class(), property)
228
+ )
229
+ if PropertyValues.same(value, default):
230
+ continue
231
+ properties[property] = serialiser.serialize_value(value)
232
+ var answer: Dictionary = {
233
+ "ok": true, "resourcePath": res_path, "resourceType": loaded.get_class(), "properties": properties
234
+ }
235
+ if script != null:
236
+ answer["script"] = script.resource_path
237
+ return answer