gdharness 1.3.11 → 1.3.13

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 (33) hide show
  1. package/build/cli.js +19152 -17965
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +49 -0
  3. package/build/godot/addons/gdharness_editor/file_walk.gd +7 -4
  4. package/build/godot/addons/gdharness_editor/serialisation.gd +2 -0
  5. package/build/godot/addons/gdharness_editor/value_shapes.gd +39 -0
  6. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +19 -0
  7. package/build/godot/addons/gdharness_runtime/serialisation.gd +2 -0
  8. package/build/godot/addons/gdharness_runtime/value_shapes.gd +39 -0
  9. package/build/godot/addons/gdharness_tests/bench.gd +28 -0
  10. package/build/godot/addons/gdharness_tests/bench.gd.uid +1 -0
  11. package/build/godot/addons/gdharness_tests/event.gd +92 -0
  12. package/build/godot/addons/gdharness_tests/event.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_tests/share.gd +289 -0
  14. package/build/godot/addons/gdharness_tests/share.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_tests/suites.gd +34 -0
  16. package/build/godot/addons/gdharness_tests/suites.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_tests/suites.tscn +6 -0
  18. package/build/godot/addons/gdharness_tests/tweens.gd +24 -0
  19. package/build/godot/addons/gdharness_tests/tweens.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_tests/wait.gd +53 -0
  21. package/build/godot/addons/gdharness_tests/wait.gd.uid +1 -0
  22. package/build/godot/loader/loader.gd +28 -0
  23. package/build/godot/operations/class_cache.gd +1 -1
  24. package/build/godot/operations/file_walk.gd +7 -4
  25. package/build/godot/operations/input_actions.gd +2 -2
  26. package/build/godot/operations/plugins.gd +2 -2
  27. package/build/godot/operations/project_config.gd +37 -10
  28. package/build/godot/operations/project_file.gd +89 -31
  29. package/build/godot/operations/serialisation.gd +2 -0
  30. package/build/godot/operations/value_shapes.gd +39 -0
  31. package/build/godot/tests/listed.gd +196 -20
  32. package/build/keeper.js +8398 -911
  33. package/package.json +1 -1
@@ -0,0 +1,289 @@
1
+ class_name GdhShare
2
+ extends RefCounted
3
+ ## Plays a bench's runs here, or this engine's share of them under `gdharness spread`, and hands
4
+ ## back every run's outcome in run order.
5
+ ##
6
+ ## A bench whose run `i` depends on `i` alone (its seed worked out from the index, nothing carried
7
+ ## from one run to the next) calls [method outcomes] for each arm with the number of runs and a
8
+ ## callable playing run `i`. Run on its own, the scene plays every run here, as it always did. Under
9
+ ## `gdharness spread`, each worker engine plays every Nth run from its own index and leaves what each
10
+ ## run answered; then one more engine runs the scene to gather, and there [method outcomes] plays
11
+ ## nothing and answers every run's outcome, read back as the type it went in as. The bench folds them
12
+ ## in run order either way, so a spread table is the same table to the last digit, floats included.
13
+ ##
14
+ ## An outcome is a plain value: a number, a string, a vector, or an array or dictionary of them. An
15
+ ## object cannot be handed from one engine to another, so one is refused with an error.
16
+
17
+ ## What `gdharness spread` tells a worker: which of how many it is.
18
+ const SHARE: String = "--gdh-share="
19
+ ## What it tells the engine gathering: how many workers left outcomes.
20
+ const GATHER: String = "--gdh-share-gather="
21
+ ## Where the workers leave their outcomes, an absolute path, since each engine has its own `user://`.
22
+ const INTO: String = "--gdh-share-dir="
23
+ ## What a worker prints as it goes, which `gdharness spread` reads to say how far each has got.
24
+ const PROGRESS: String = "gdharness share: %s %d of %d runs"
25
+
26
+ static var _learnt: bool = false
27
+ static var _index: int = -1
28
+ static var _workers: int = 0
29
+ static var _gathering: int = 0
30
+ static var _directory: String = ""
31
+ static var _arms: Dictionary[String, bool] = {}
32
+ static var _read: bool = false
33
+ static var _unread: String = ""
34
+ static var _found: Dictionary[String, Dictionary] = {}
35
+ static var _found_runs: Dictionary[String, int] = {}
36
+
37
+
38
+ ## Whether this engine is one of the workers of a spread run, which prints no table.
39
+ static func working() -> bool:
40
+ _learn()
41
+ return _index >= 0
42
+
43
+
44
+ ## Whether this engine is the one gathering a spread run's outcomes.
45
+ static func gathering() -> bool:
46
+ _learn()
47
+ return _gathering > 0
48
+
49
+
50
+ ## The runs of [param runs] this engine plays: every one on its own, its share as a worker (every
51
+ ## Nth from its own index, so each worker draws long runs and short ones alike), and none when it
52
+ ## gathers.
53
+ static func dealt(runs: int) -> PackedInt32Array:
54
+ _learn()
55
+ var played: PackedInt32Array = []
56
+ if gathering():
57
+ return played
58
+ var step: int = _workers if working() else 1
59
+ for at: int in range(maxi(_index, 0), runs, step):
60
+ var _added: bool = played.append(at)
61
+ return played
62
+
63
+
64
+ ## Every outcome of the arm [param arm]'s [param runs] runs, in run order: played here by
65
+ ## [param play], which is called with a run's index, or gathered from the workers. A worker plays
66
+ ## its share, leaves the outcomes for the engine gathering, and answers an empty array.
67
+ static func outcomes(arm: String, runs: int, play: Callable) -> Array:
68
+ _learn()
69
+ if _arms.has(arm):
70
+ push_error("GdhShare: the arm %s was played twice in one run; give each arm its own name" % arm)
71
+ return []
72
+ _arms[arm] = true
73
+ if gathering():
74
+ return _gathered(arm, runs)
75
+
76
+ var by_index: Dictionary[int, Variant] = {}
77
+ var share: PackedInt32Array = dealt(runs)
78
+ var every: int = maxi(ceili(share.size() / 10.0), 1)
79
+ for at: int in range(share.size()):
80
+ var run: int = share[at]
81
+ var outcome: Variant = play.call(run)
82
+ var wrong: String = _not_plain(outcome)
83
+ if not wrong.is_empty():
84
+ push_error(
85
+ (
86
+ "GdhShare: run %d of %s answered %s, which cannot be handed from one engine to another"
87
+ % [run, arm, wrong]
88
+ )
89
+ )
90
+ by_index[run] = outcome
91
+ if working() and ((at + 1) % every == 0 or at + 1 == share.size()):
92
+ print(PROGRESS % [arm, at + 1, share.size()])
93
+
94
+ if working():
95
+ _leave(arm, runs, by_index)
96
+ return []
97
+ var ordered: Array = []
98
+ for run: int in range(runs):
99
+ ordered.append(by_index[run])
100
+ return ordered
101
+
102
+
103
+ ## [param counted], each a dictionary of whole numbers, added up key by key; a key named in
104
+ ## [param largest] keeps the largest instead. Adding and taking the largest come out the same in
105
+ ## any order, which is what lets a bench fold its runs however it likes.
106
+ static func added(
107
+ counted: Array, largest: PackedStringArray = PackedStringArray()
108
+ ) -> Dictionary[String, int]:
109
+ var total: Dictionary[String, int] = {}
110
+ for outcome: Variant in counted:
111
+ if not outcome is Dictionary:
112
+ push_error("GdhShare.added: an outcome is %s, not a dictionary" % type_string(typeof(outcome)))
113
+ continue
114
+ var counts: Dictionary = outcome
115
+ for key: Variant in counts:
116
+ var named: String = str(key)
117
+ var count: Variant = counts[key]
118
+ if not count is int:
119
+ push_error(
120
+ "GdhShare.added: %s is %s, not a whole number" % [named, type_string(typeof(count))]
121
+ )
122
+ continue
123
+ var now: int = count
124
+ if not total.has(named):
125
+ total[named] = now
126
+ elif largest.has(named):
127
+ total[named] = maxi(total[named], now)
128
+ else:
129
+ total[named] += now
130
+ return total
131
+
132
+
133
+ ## Leaves one arm's outcomes, then the line saying so, which `gdharness spread` checks the share
134
+ ## against; the outcomes first, so a line is never there without them.
135
+ static func _leave(arm: String, runs: int, by_index: Dictionary[int, Variant]) -> void:
136
+ var left: Dictionary = {"arm": arm, "runs": runs, "outcomes": by_index}
137
+ if not _appended("worker-%d.bin" % _index, func(file: FileAccess) -> bool: return file.store_var(left)):
138
+ return
139
+ var said: String = JSON.stringify({"arm": arm, "runs": runs, "played": by_index.size()})
140
+ var _noted: bool = _appended(
141
+ "worker-%d.arms" % _index, func(file: FileAccess) -> bool: return file.store_line(said)
142
+ )
143
+
144
+
145
+ ## Appends to [param name] in the share directory with [param write], answering whether it did.
146
+ static func _appended(name: String, write: Callable) -> bool:
147
+ var path: String = _directory.path_join(name)
148
+ var file: FileAccess = FileAccess.open(
149
+ path, FileAccess.READ_WRITE if FileAccess.file_exists(path) else FileAccess.WRITE
150
+ )
151
+ if file == null:
152
+ push_error("GdhShare: cannot write %s: %s" % [path, error_string(FileAccess.get_open_error())])
153
+ return false
154
+ file.seek_end()
155
+ var wrote: bool = write.call(file)
156
+ file.close()
157
+ if not wrote:
158
+ push_error("GdhShare: cannot write %s" % path)
159
+ return wrote
160
+
161
+
162
+ static func _gathered(arm: String, runs: int) -> Array:
163
+ _read_workers()
164
+ if not _unread.is_empty():
165
+ push_error(_unread)
166
+ return []
167
+ if not _found.has(arm):
168
+ push_error(
169
+ (
170
+ (
171
+ "GdhShare: no worker played the arm %s, so this run went another way than theirs; "
172
+ + "which arms a bench plays must depend on its command line alone"
173
+ )
174
+ % arm
175
+ )
176
+ )
177
+ return []
178
+ if _found_runs[arm] != runs:
179
+ push_error(
180
+ (
181
+ "GdhShare: the workers played %d runs of %s, and this run asks for %d"
182
+ % [_found_runs[arm], arm, runs]
183
+ )
184
+ )
185
+ return []
186
+ var held: Dictionary = _found[arm]
187
+ var ordered: Array = []
188
+ for run: int in range(runs):
189
+ if not held.has(run):
190
+ push_error("GdhShare: no worker played run %d of %s" % [run, arm])
191
+ return []
192
+ ordered.append(held[run])
193
+ var said: String = JSON.stringify({"arm": arm, "runs": runs, "played": runs})
194
+ var _noted: bool = _appended(
195
+ "gathered.arms", func(file: FileAccess) -> bool: return file.store_line(said)
196
+ )
197
+ return ordered
198
+
199
+
200
+ static func _read_workers() -> void:
201
+ if _read:
202
+ return
203
+ _read = true
204
+ for worker: int in range(_gathering):
205
+ var path: String = _directory.path_join("worker-%d.bin" % worker)
206
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
207
+ if file == null:
208
+ _unread = "GdhShare: cannot read %s: %s" % [path, error_string(FileAccess.get_open_error())]
209
+ return
210
+ while file.get_position() < file.get_length():
211
+ _unread = _took(file.get_var(), path)
212
+ if not _unread.is_empty():
213
+ file.close()
214
+ return
215
+ file.close()
216
+
217
+
218
+ ## Adds one arm a worker left to what has been found, answering why it cannot, or empty.
219
+ static func _took(left: Variant, path: String) -> String:
220
+ if not left is Dictionary:
221
+ return "GdhShare: %s holds something other than a worker's outcomes" % path
222
+ var record: Dictionary = left
223
+ var arm_said: Variant = record.get("arm")
224
+ var runs_said: Variant = record.get("runs")
225
+ var outcomes_said: Variant = record.get("outcomes")
226
+ if not (arm_said is String and runs_said is int and outcomes_said is Dictionary):
227
+ return "GdhShare: %s holds something other than a worker's outcomes" % path
228
+ var arm: String = arm_said
229
+ var runs: int = runs_said
230
+ var played: Dictionary = outcomes_said
231
+ if _found_runs.has(arm) and _found_runs[arm] != runs:
232
+ return (
233
+ "GdhShare: the workers disagree on how many runs %s has: %d and %d"
234
+ % [arm, _found_runs[arm], runs]
235
+ )
236
+ _found_runs[arm] = runs
237
+ var held: Dictionary = _found.get_or_add(arm, {})
238
+ for run: Variant in played:
239
+ if held.has(run):
240
+ return "GdhShare: run %s of %s was played by two workers" % [str(run), arm]
241
+ held[run] = played[run]
242
+ return ""
243
+
244
+
245
+ static func _learn() -> void:
246
+ if _learnt:
247
+ return
248
+ _learnt = true
249
+ for given: String in OS.get_cmdline_user_args():
250
+ if given.begins_with(SHARE):
251
+ var halves: PackedStringArray = given.trim_prefix(SHARE).split("/")
252
+ if halves.size() == 2 and halves[0].is_valid_int() and halves[1].is_valid_int():
253
+ _index = halves[0].to_int()
254
+ _workers = halves[1].to_int()
255
+ if _index < 0 or _index >= _workers:
256
+ push_error("GdhShare: %s names no worker; it takes <index>/<workers>" % given)
257
+ _index = -1
258
+ elif given.begins_with(GATHER):
259
+ _gathering = given.trim_prefix(GATHER).to_int()
260
+ elif given.begins_with(INTO):
261
+ _directory = given.trim_prefix(INTO)
262
+ if (_index >= 0 or _gathering > 0) and _directory.is_empty():
263
+ push_error("GdhShare: a spread engine was given no %s<directory> to share outcomes in" % INTO)
264
+
265
+
266
+ ## What in [param value] cannot be written by one engine and read by another, or empty.
267
+ static func _not_plain(value: Variant) -> String:
268
+ match typeof(value):
269
+ TYPE_OBJECT:
270
+ return "an object"
271
+ TYPE_CALLABLE:
272
+ return "a callable"
273
+ TYPE_SIGNAL:
274
+ return "a signal"
275
+ TYPE_RID:
276
+ return "a resource id"
277
+ TYPE_ARRAY:
278
+ var items: Array = value
279
+ for item: Variant in items:
280
+ var wrong: String = _not_plain(item)
281
+ if not wrong.is_empty():
282
+ return wrong
283
+ TYPE_DICTIONARY:
284
+ var pairs: Dictionary = value
285
+ for key: Variant in pairs:
286
+ var wrong: String = _not_plain(key) + _not_plain(pairs[key])
287
+ if not wrong.is_empty():
288
+ return wrong
289
+ return ""
@@ -0,0 +1 @@
1
+ uid://bxqhnwjb1qap8
@@ -0,0 +1,34 @@
1
+ extends Node
2
+
3
+ ## gdUnit4 suites run inside a played scene, which the engine's script profiler sees and a script
4
+ ## run with `-s` never does. `editor_run start` given `suites` plays this scene with
5
+ ## [constant RUNNER] naming the runner `gdharness test` uses, where the server ships it, followed by
6
+ ## that runner's own arguments. The runner is loaded from there rather than kept in this addon,
7
+ ## since it extends gdUnit4's classes and a project without gdUnit4 must still compile the addon.
8
+
9
+ const RUNNER: String = "--gdh-runner"
10
+
11
+
12
+ func _ready() -> void:
13
+ var asked: PackedStringArray = OS.get_cmdline_user_args()
14
+ var at: int = asked.find(RUNNER)
15
+ var path: String = asked[at + 1] if at >= 0 and at + 1 < asked.size() else ""
16
+ var listed: GDScript = null
17
+ if not path.is_empty() and FileAccess.file_exists(path):
18
+ listed = load(path) as GDScript
19
+ if listed == null:
20
+ push_error(
21
+ (
22
+ (
23
+ "gdharness suites: no runner at '%s'. This scene is played by editor_run start with suites,"
24
+ + " which names the runner after %s; it does nothing played any other way."
25
+ )
26
+ % [path, RUNNER]
27
+ )
28
+ )
29
+ get_tree().quit(1)
30
+ return
31
+ var runner: Node = listed.call("started", asked.slice(at + 2))
32
+ # The runner holds its own script past the engine's shutdown by this path.
33
+ runner.set("own_path", path)
34
+ add_child(runner)
@@ -0,0 +1 @@
1
+ uid://bq3n7xkd2wfs0
@@ -0,0 +1,6 @@
1
+ [gd_scene format=3 uid="uid://c8vtk4rm1ynho"]
2
+
3
+ [ext_resource type="Script" uid="uid://bq3n7xkd2wfs0" path="res://addons/gdharness_tests/suites.gd" id="1_suites"]
4
+
5
+ [node name="GdhSuites" type="Node"]
6
+ script = ExtResource("1_suites")
@@ -0,0 +1,24 @@
1
+ class_name GdhTweens
2
+ extends RefCounted
3
+ ## Moves the tree's animations on by a time the case chooses, rather than waiting for them.
4
+ ##
5
+ ## A case that waits a real timer of an animation's length races the animation: the tree runs its
6
+ ## timers before its tweens, so on a busy machine the timer fires ahead of the last step and the
7
+ ## case reads a fade short of its end. Stepping the tweens reads the slide or the fade exactly
8
+ ## where the case asks.
9
+
10
+ ## A frame at sixty a second.
11
+ const FRAME: float = 1.0 / 60.0
12
+
13
+
14
+ ## Steps every tween the tree is processing by [param seconds], as frames that long would.
15
+ static func step(tree: SceneTree, seconds: float) -> void:
16
+ for running: Tween in tree.get_processed_tweens():
17
+ var _stepped: bool = running.custom_step(seconds)
18
+
19
+
20
+ ## [method step] [param seconds] and one frame more, for a case about what an animation leaves once
21
+ ## it is over: a step that ends exactly where the last tweener does leaves a callback after it
22
+ ## waiting for time it has not been given.
23
+ static func past(tree: SceneTree, seconds: float) -> void:
24
+ step(tree, seconds + FRAME)
@@ -0,0 +1 @@
1
+ uid://dlsfeajehdbca
@@ -0,0 +1,53 @@
1
+ class_name GdhWait
2
+ extends RefCounted
3
+ ## Waits out what the game is doing by asking every frame whether it is done, never by a timer.
4
+ ##
5
+ ## A timer of an animation's length and a margin is a race the animation loses on a busy machine:
6
+ ## the engine counts a tween's time in the frames' deltas, and a frame that waits on other engines
7
+ ## arrives late, so the timer ends with the tween still running. Asking once a frame holds however
8
+ ## slowly the frames come. The bound is on the clock only so that something that never ends fails
9
+ ## the case by name instead of holding the run until its silence ends it.
10
+
11
+ ## How long [method until] and [method still] wait by default, in milliseconds.
12
+ const MOST_MS: int = 30000
13
+
14
+ ## Frames in a row with no tween running before [method still] counts the tree as still. A layout
15
+ ## often starts its slide a frame after the change that asks for it, so the frame straight after
16
+ ## had no tween yet and passed for still.
17
+ const QUIET_FRAMES: int = 3
18
+
19
+
20
+ ## Whether [param done] answered true within [param most_ms], asked now and then once a frame.
21
+ static func until(tree: SceneTree, done: Callable, most_ms: int = MOST_MS) -> bool:
22
+ var began: int = Time.get_ticks_msec()
23
+ while not done.call():
24
+ if Time.get_ticks_msec() - began > most_ms:
25
+ return false
26
+ await tree.process_frame
27
+ return true
28
+
29
+
30
+ ## Whether the tree ran no tween for [param quiet_frames] frames in a row within [param most_ms].
31
+ static func still(tree: SceneTree, quiet_frames: int = QUIET_FRAMES, most_ms: int = MOST_MS) -> bool:
32
+ var began: int = Time.get_ticks_msec()
33
+ var quiet: int = 0
34
+ while quiet < quiet_frames:
35
+ if Time.get_ticks_msec() - began > most_ms:
36
+ return false
37
+ await tree.process_frame
38
+ quiet = quiet + 1 if not running(tree) else 0
39
+ return true
40
+
41
+
42
+ ## [param count] frames of the tree, waited one at a time.
43
+ static func frames(tree: SceneTree, count: int) -> void:
44
+ for _frame: int in range(count):
45
+ await tree.process_frame
46
+
47
+
48
+ ## Whether any tween under [param tree] is running.
49
+ static func running(tree: SceneTree) -> bool:
50
+ for tween: Tween in tree.get_processed_tweens():
51
+ if tween.is_running():
52
+ return true
53
+ return false
@@ -0,0 +1 @@
1
+ uid://wq6ytbgoow47
@@ -0,0 +1,28 @@
1
+ extends Node
2
+ ## Brings gdharness's runtime up in a debug build that has its addon, and does nothing anywhere else.
3
+ ##
4
+ ## `project.godot` names this file rather than the addon's script. `addons/` is often not committed
5
+ ## and exports leave it out, so an entry naming the addon boots a fresh clone or an exported build
6
+ ## with a missing script. `gdharness setup` writes this file, and `gdharness doctor` says when it is
7
+ ## not the one that gdharness ships. The runtime refuses a `-s` script run by itself unless
8
+ ## `gdharness/runtime/serve_script_runs` is on, so that is left to it.
9
+
10
+ ## Loaded by path as it runs rather than preloaded: a preload resolves when this script is parsed,
11
+ ## and in a build without the addon that is the missing script this file is here to avoid.
12
+ const RUNTIME: String = "res://addons/gdharness_runtime/runtime_autoload.gd"
13
+
14
+
15
+ func _ready() -> void:
16
+ if not OS.is_debug_build() or not ResourceLoader.exists(RUNTIME):
17
+ return
18
+ var script: GDScript = load(RUNTIME) as GDScript
19
+ if script == null:
20
+ return
21
+ var made: Object = script.new()
22
+ var runtime: Node = made as Node
23
+ if runtime == null:
24
+ return
25
+ # Under this node rather than beside it: the root refuses a child while it is still adding the
26
+ # autoloads, and a deferred add would bring the runtime up only after the main scene's _ready.
27
+ # Added now, it announces before the main scene runs, as it does when an entry names it.
28
+ add_child(runtime)
@@ -26,7 +26,7 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
26
26
 
27
27
  var entries: Array = []
28
28
  var skipped: Array[Dictionary] = []
29
- for path: String in _files.find_files("res://", ".gd"):
29
+ for path: String in _files.find_files("res://", ".gd", true):
30
30
  var entry: Dictionary = _entry_for(path, skipped)
31
31
  if not entry.is_empty():
32
32
  entries.append(entry)
@@ -77,10 +77,13 @@ func is_installed_by_gdharness(path: String) -> bool:
77
77
  return FileAccess.file_exists(path + ".gdharness-version")
78
78
 
79
79
 
80
- # Files under `path` whose name ends with `extension`, which is passed with its dot.
81
- func find_files(path: String, extension: String) -> Array[String]:
80
+ # Files under `path` whose name ends with `extension`, which is passed with its dot. An addon
81
+ # gdharness installed is walked into when [param with_ours] is set, for a caller rebuilding what
82
+ # the engine itself would: the engine lists the global classes of every addon, and a list rebuilt
83
+ # without ours dropped the one class the tests addon declares.
84
+ func find_files(path: String, extension: String, with_ours: bool = false) -> Array[String]:
82
85
  var files: Array[String] = []
83
- if is_stepped_over(path) or is_installed_by_gdharness(path):
86
+ if is_stepped_over(path) or (not with_ours and is_installed_by_gdharness(path)):
84
87
  return files
85
88
 
86
89
  var dir: DirAccess = DirAccess.open(path)
@@ -92,7 +95,7 @@ func find_files(path: String, extension: String) -> Array[String]:
92
95
 
93
96
  while file_name != "":
94
97
  if dir.current_is_dir() and not file_name.begins_with("."):
95
- files.append_array(find_files(path + file_name + "/", extension))
98
+ files.append_array(find_files(path + file_name + "/", extension, with_ours))
96
99
  elif file_name.ends_with(extension):
97
100
  files.append(path + file_name)
98
101
 
@@ -191,7 +191,7 @@ func add_input_action(params: Dictionary) -> Dictionary:
191
191
  # Through ProjectSettings so the file is saved the way the editor saves it, header and
192
192
  # every other line kept; a ConfigFile of project.godot drops the comments on the way out.
193
193
  ProjectSettings.set_setting(setting, action)
194
- var err: Error = GdhProjectFile.save()
194
+ var err: Error = GdhProjectFile.save([setting])
195
195
  if err != OK:
196
196
  return _log.failure("Failed to save project.godot: " + error_string(err))
197
197
 
@@ -297,7 +297,7 @@ func remove_input_action(params: Dictionary) -> Dictionary:
297
297
  )
298
298
  )
299
299
  ProjectSettings.set_setting(setting, null)
300
- var err: Error = GdhProjectFile.save()
300
+ var err: Error = GdhProjectFile.save([setting])
301
301
  if err != OK:
302
302
  return _log.failure("Failed to save project.godot: " + error_string(err))
303
303
  return {"action_name": action_name, "removed": true}
@@ -172,11 +172,11 @@ static func _names_of(paths: Array[String]) -> Array[String]:
172
172
  func _save_enabled_paths(paths: Array[String]) -> Error:
173
173
  if paths.is_empty():
174
174
  ProjectSettings.set_setting("editor_plugins/enabled", null)
175
- return GdhProjectFile.save()
175
+ return GdhProjectFile.save(["editor_plugins/enabled"])
176
176
 
177
177
  # Kept in an Array[String] and converted here, because appending to a packed array answers with
178
178
  # whether it worked and a project holding return_value_discarded at error level will not compile
179
179
  # a script that drops that answer. The conversion is what matters: an Array[String] handed to
180
180
  # set_setting is written as Array[String]([...]), which the editor does not read as a plugin list.
181
181
  ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(paths))
182
- return GdhProjectFile.save()
182
+ return GdhProjectFile.save(["editor_plugins/enabled"])
@@ -47,9 +47,11 @@ func set_project_setting(params: Dictionary) -> Dictionary:
47
47
  old_value = ProjectSettings.get_setting(setting_path)
48
48
  # A setting the engine registers is there with its default whether or not project.godot sets it,
49
49
  # so has_setting alone called the description of a project that had never set one an old value.
50
- # At its default, the file does not set it: the engine drops a key at its default when it saves.
50
+ # Whether it is new is the file's to say: one at its default has a line only where the project
51
+ # wrote it out on purpose.
51
52
  var default_value: Variant = ProjectSettings.property_get_revert(setting_path)
52
53
  var was_default: bool = had_value and default_value != null and old_value == default_value
54
+ var in_file: bool = GdhProjectFile.sets(setting_path)
53
55
 
54
56
  var wanted: int = _wanted_type(setting_path, old_value if had_value else null)
55
57
  # A value of fields or a colour's html code is fitted as a property write fits it, so a value one
@@ -68,12 +70,18 @@ func set_project_setting(params: Dictionary) -> Dictionary:
68
70
  var number: float = final_value
69
71
  if number == floorf(number) and absf(number) < 9.0e15:
70
72
  final_value = int(number)
73
+ # A tag that could not be built is still the dictionary it arrived as, and a setting nothing
74
+ # declares took it as one: the file was given the tag key by key, beside an answer of success.
75
+ if final_value is Dictionary:
76
+ var held: Dictionary = final_value
77
+ if held.has("_type"):
78
+ return _log.failure(_unbuilt(setting_path, held))
71
79
 
72
80
  var answer: Dictionary = {
73
81
  "setting_path": setting_path,
74
82
  "old_value": _values.serialize_value(old_value) if had_value else null,
75
83
  "new_value": _values.serialize_value(final_value),
76
- "was_new": not had_value or was_default,
84
+ "was_new": not in_file,
77
85
  "was_default": was_default,
78
86
  "saved": true,
79
87
  }
@@ -82,7 +90,7 @@ func set_project_setting(params: Dictionary) -> Dictionary:
82
90
  if had_value and typeof(old_value) == typeof(final_value) and old_value == final_value:
83
91
  answer["saved"] = false
84
92
  answer["unchanged"] = true
85
- if was_default:
93
+ if was_default and not in_file:
86
94
  answer["note"] = (
87
95
  (
88
96
  "%s is already this setting's default, which project.godot holds no line for:"
@@ -122,7 +130,7 @@ func set_project_setting(params: Dictionary) -> Dictionary:
122
130
  said += ("; project_import reimport makes the translation files from " + ", ".join(made))
123
131
  answer["note"] = (str(answer["note"]) + " " if answer.has("note") else "") + said + "."
124
132
  ProjectSettings.set_setting(setting_path, final_value)
125
- var err: Error = GdhProjectFile.save()
133
+ var err: Error = GdhProjectFile.save([setting_path])
126
134
  if err != OK:
127
135
  return _log.failure("Failed to save project.godot: " + error_string(err))
128
136
  return answer
@@ -180,8 +188,9 @@ func unset_project_setting(params: Dictionary) -> Dictionary:
180
188
  "setting_path": setting_path,
181
189
  "default_value": _values.serialize_value(default_value) if default_value != null else null,
182
190
  }
183
- # A setting at the engine's default has no line to take out, as the engine leaves it out of the file.
184
- if not held or (default_value != null and value == default_value):
191
+ # Read off the file rather than the value: a setting at its default usually has no line, as the
192
+ # engine leaves it out, but a project can hold one written out on purpose.
193
+ if not held or not GdhProjectFile.sets(setting_path):
185
194
  answer["removed"] = false
186
195
  answer["saved"] = false
187
196
  answer["note"] = "project.godot does not set %s, so there was nothing to take out." % setting_path
@@ -190,7 +199,7 @@ func unset_project_setting(params: Dictionary) -> Dictionary:
190
199
  # The default rather than null for a setting the engine declares: null takes the declaration away
191
200
  # as well, and a value at its default is left out of the file all the same.
192
201
  ProjectSettings.set_setting(setting_path, default_value)
193
- var err: Error = GdhProjectFile.save()
202
+ var err: Error = GdhProjectFile.save([setting_path])
194
203
  if err != OK:
195
204
  return _log.failure("Failed to save project.godot: " + error_string(err))
196
205
  answer["removed"] = true
@@ -266,6 +275,24 @@ static func _cannot_hold(setting_path: String, wanted: int, value: Variant) -> S
266
275
  return "%s, and %s (%s) cannot become one.%s" % [declared, JSON.stringify(value), given, remedy]
267
276
 
268
277
 
278
+ ## Why [param tagged], a value whose tag could not be built, was not written to [param setting_path],
279
+ ## and how a value of that tag is written.
280
+ static func _unbuilt(setting_path: String, tagged: Dictionary) -> String:
281
+ var tag: String = str(tagged["_type"])
282
+ var refused: String = (
283
+ "The value for %s is tagged %s and could not be read as one, so nothing was written."
284
+ % [setting_path, JSON.stringify(tag)]
285
+ )
286
+ var shapes: Array = GdhShapes.REQUIRED.get(tag, [])
287
+ if shapes.is_empty():
288
+ return refused + " A setting cannot hold a %s written as a tag." % tag
289
+ var keys: Array = shapes[0]
290
+ var parts: Array[String] = ['"_type": "%s"' % tag]
291
+ for key: Variant in keys:
292
+ parts.append('"%s": %s' % [key, "[...]" if tag.begins_with("Packed") and key == "value" else "..."])
293
+ return refused + " One is written as {%s}." % ", ".join(parts)
294
+
295
+
269
296
  # Add an autoload singleton
270
297
  func add_autoload(params: Dictionary) -> Dictionary:
271
298
  var name: String = str(params.get("name", ""))
@@ -313,7 +340,7 @@ func add_autoload(params: Dictionary) -> Dictionary:
313
340
  "saved": false
314
341
  }
315
342
  ProjectSettings.set_setting(setting, entry)
316
- var err: Error = GdhProjectFile.save()
343
+ var err: Error = GdhProjectFile.save([setting])
317
344
  if err != OK:
318
345
  return _log.failure("Failed to save project.godot: " + error_string(err))
319
346
 
@@ -339,7 +366,7 @@ func remove_autoload(params: Dictionary) -> Dictionary:
339
366
 
340
367
  var old_value: String = str(ProjectSettings.get_setting(setting))
341
368
  ProjectSettings.set_setting(setting, null)
342
- var err: Error = GdhProjectFile.save()
369
+ var err: Error = GdhProjectFile.save([setting])
343
370
  if err != OK:
344
371
  return _log.failure("Failed to save project.godot: " + error_string(err))
345
372
 
@@ -401,7 +428,7 @@ func set_main_scene(params: Dictionary) -> Dictionary:
401
428
  var old_main_scene: String = str(ProjectSettings.get_setting("application/run/main_scene", ""))
402
429
 
403
430
  ProjectSettings.set_setting("application/run/main_scene", scene_path)
404
- var err: Error = GdhProjectFile.save()
431
+ var err: Error = GdhProjectFile.save(["application/run/main_scene"])
405
432
 
406
433
  if err != OK:
407
434
  return _log.failure("Failed to save project settings: " + str(err))