gdharness 1.2.25 → 1.2.26
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.
- package/build/cli.js +82 -16
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +290 -0
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +7 -0
- package/build/godot/addons/gdharness_runtime/runtime_pad.gd +150 -0
- package/build/godot/addons/gdharness_runtime/runtime_pad.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +5 -0
- package/build/godot/operations/import_pipeline.gd +92 -0
- package/build/godot/operations/input_actions.gd +3 -2
- package/build/godot/operations/plugins.gd +3 -2
- package/build/godot/operations/project_config.gd +17 -9
- package/build/godot/operations/project_file.gd +202 -0
- package/build/index.js +82 -16
- package/build/keeper.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
## What reaches the running game the way a controller reaches it: a button pressed and a stick or
|
|
4
|
+
## trigger moved, as the events a pad sends.
|
|
5
|
+
##
|
|
6
|
+
## The events themselves rather than the actions bound to them: a game reading the pad in _input or
|
|
7
|
+
## _unhandled_input looks for InputEventJoypadButton and InputEventJoypadMotion, and an action
|
|
8
|
+
## reached it as an InputEventAction it never reads, so a controller layout could only be driven by
|
|
9
|
+
## calling the game's own methods. The engine still fires the actions bound to a button from these.
|
|
10
|
+
|
|
11
|
+
const Read = preload("reading.gd")
|
|
12
|
+
|
|
13
|
+
## Controller buttons by the names Godot gives them, for a caller naming one rather than numbering it.
|
|
14
|
+
const JOY_BUTTONS: Dictionary[String, int] = {
|
|
15
|
+
"a": JOY_BUTTON_A,
|
|
16
|
+
"b": JOY_BUTTON_B,
|
|
17
|
+
"x": JOY_BUTTON_X,
|
|
18
|
+
"y": JOY_BUTTON_Y,
|
|
19
|
+
"back": JOY_BUTTON_BACK,
|
|
20
|
+
"guide": JOY_BUTTON_GUIDE,
|
|
21
|
+
"start": JOY_BUTTON_START,
|
|
22
|
+
"left_stick": JOY_BUTTON_LEFT_STICK,
|
|
23
|
+
"right_stick": JOY_BUTTON_RIGHT_STICK,
|
|
24
|
+
"left_shoulder": JOY_BUTTON_LEFT_SHOULDER,
|
|
25
|
+
"right_shoulder": JOY_BUTTON_RIGHT_SHOULDER,
|
|
26
|
+
"dpad_up": JOY_BUTTON_DPAD_UP,
|
|
27
|
+
"dpad_down": JOY_BUTTON_DPAD_DOWN,
|
|
28
|
+
"dpad_left": JOY_BUTTON_DPAD_LEFT,
|
|
29
|
+
"dpad_right": JOY_BUTTON_DPAD_RIGHT,
|
|
30
|
+
"misc1": JOY_BUTTON_MISC1,
|
|
31
|
+
"paddle1": JOY_BUTTON_PADDLE1,
|
|
32
|
+
"paddle2": JOY_BUTTON_PADDLE2,
|
|
33
|
+
"paddle3": JOY_BUTTON_PADDLE3,
|
|
34
|
+
"paddle4": JOY_BUTTON_PADDLE4,
|
|
35
|
+
"touchpad": JOY_BUTTON_TOUCHPAD,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
## Stick and trigger axes by the names Godot gives them.
|
|
39
|
+
const JOY_AXES: Dictionary[String, int] = {
|
|
40
|
+
"left_x": JOY_AXIS_LEFT_X,
|
|
41
|
+
"left_y": JOY_AXIS_LEFT_Y,
|
|
42
|
+
"right_x": JOY_AXIS_RIGHT_X,
|
|
43
|
+
"right_y": JOY_AXIS_RIGHT_Y,
|
|
44
|
+
"trigger_left": JOY_AXIS_TRIGGER_LEFT,
|
|
45
|
+
"trigger_right": JOY_AXIS_TRIGGER_RIGHT,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var _host: Node
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
func _init(host: Node) -> void:
|
|
52
|
+
_host = host
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Presses a controller button, and lets go of it again unless asked to hold it, as a key press does:
|
|
56
|
+
## a button left down is a press that never ends, and everything reading it goes on seeing it.
|
|
57
|
+
func inject_joypad_button(params: Dictionary) -> Dictionary:
|
|
58
|
+
var given: Variant = params.get("button", null)
|
|
59
|
+
var button: int = _named_or_numbered(given, JOY_BUTTONS, JOY_BUTTON_MAX)
|
|
60
|
+
if button < 0:
|
|
61
|
+
return {"type": "error", "message": _not_one_of("button", given, JOY_BUTTONS, JOY_BUTTON_MAX)}
|
|
62
|
+
var device: int = Read.as_int(params.get("device", 0), 0)
|
|
63
|
+
var held: bool = Read.as_bool(params.get("pressed", true), true)
|
|
64
|
+
var whole: bool = not params.has("pressed")
|
|
65
|
+
|
|
66
|
+
Input.parse_input_event(_button(button, device, whole or held))
|
|
67
|
+
if whole:
|
|
68
|
+
# A frame between the halves, as a key has, so a game that acts on the press and one that
|
|
69
|
+
# acts on the release each get a frame to do it in.
|
|
70
|
+
await _host.get_tree().process_frame
|
|
71
|
+
Input.parse_input_event(_button(button, device, false))
|
|
72
|
+
await _host.get_tree().process_frame
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"type": "input_injected",
|
|
76
|
+
"input_type": "joypad_button",
|
|
77
|
+
"button": button,
|
|
78
|
+
"button_name": _name_of(button, JOY_BUTTONS),
|
|
79
|
+
"device": device,
|
|
80
|
+
"pressed": held and not whole,
|
|
81
|
+
"whole": whole,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## Moves a stick or trigger axis to a value, where it stays until another motion moves it, as a
|
|
86
|
+
## stick held over does.
|
|
87
|
+
func inject_joypad_motion(params: Dictionary) -> Dictionary:
|
|
88
|
+
var given: Variant = params.get("axis", null)
|
|
89
|
+
var axis: int = _named_or_numbered(given, JOY_AXES, JOY_AXIS_MAX)
|
|
90
|
+
if axis < 0:
|
|
91
|
+
return {"type": "error", "message": _not_one_of("axis", given, JOY_AXES, JOY_AXIS_MAX)}
|
|
92
|
+
if not params.has("value"):
|
|
93
|
+
return {"type": "error", "message": "joypad_motion needs value, where the axis is, -1 to 1"}
|
|
94
|
+
var value: float = Read.as_float(params.get("value"), 0.0)
|
|
95
|
+
if value < -1.0 or value > 1.0:
|
|
96
|
+
return {
|
|
97
|
+
"type": "error", "message": "value %s is outside -1 to 1, the whole travel of an axis" % value
|
|
98
|
+
}
|
|
99
|
+
var device: int = Read.as_int(params.get("device", 0), 0)
|
|
100
|
+
|
|
101
|
+
var event: InputEventJoypadMotion = InputEventJoypadMotion.new()
|
|
102
|
+
event.axis = axis as JoyAxis
|
|
103
|
+
event.axis_value = value
|
|
104
|
+
event.device = device
|
|
105
|
+
Input.parse_input_event(event)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"type": "input_injected",
|
|
109
|
+
"input_type": "joypad_motion",
|
|
110
|
+
"axis": axis,
|
|
111
|
+
"axis_name": _name_of(axis, JOY_AXES),
|
|
112
|
+
"value": value,
|
|
113
|
+
"device": device,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
static func _button(button: int, device: int, down: bool) -> InputEventJoypadButton:
|
|
118
|
+
var event: InputEventJoypadButton = InputEventJoypadButton.new()
|
|
119
|
+
event.button_index = button as JoyButton
|
|
120
|
+
event.pressed = down
|
|
121
|
+
event.device = device
|
|
122
|
+
return event
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
## [param given] as one of [param names], or as a number below [param count], or -1 for neither.
|
|
126
|
+
static func _named_or_numbered(given: Variant, names: Dictionary[String, int], count: int) -> int:
|
|
127
|
+
if given is String:
|
|
128
|
+
var named: String = given
|
|
129
|
+
return Read.as_int(names.get(named.strip_edges().to_lower(), -1), -1)
|
|
130
|
+
if given is int or given is float:
|
|
131
|
+
var number: int = Read.as_int(given, -1)
|
|
132
|
+
return number if number >= 0 and number < count else -1
|
|
133
|
+
return -1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
static func _not_one_of(
|
|
137
|
+
argument: String, given: Variant, names: Dictionary[String, int], count: int
|
|
138
|
+
) -> String:
|
|
139
|
+
return (
|
|
140
|
+
"%s %s is not one: give its name, one of %s, or its number, 0 to %d"
|
|
141
|
+
% [argument, JSON.stringify(given), ", ".join(PackedStringArray(names.keys())), count - 1]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## The name [param number] goes by in [param names], or empty for a number with none.
|
|
146
|
+
static func _name_of(number: int, names: Dictionary[String, int]) -> String:
|
|
147
|
+
for named: String in names:
|
|
148
|
+
if names[named] == number:
|
|
149
|
+
return named
|
|
150
|
+
return ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://cj164fjnsnn1y
|
|
@@ -718,6 +718,11 @@ func get_metrics(params: Dictionary) -> Dictionary:
|
|
|
718
718
|
"render_total_objects": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
|
|
719
719
|
"render_total_primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
|
|
720
720
|
"render_total_draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
|
|
721
|
+
# The GPU's half of the game's memory, in bytes, which memory_static does not count: a game
|
|
722
|
+
# committing 2.3 GB answered 298 MB there, and the rest could not be asked about.
|
|
723
|
+
"render_video_mem_used": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED),
|
|
724
|
+
"render_texture_mem_used": Performance.get_monitor(Performance.RENDER_TEXTURE_MEM_USED),
|
|
725
|
+
"render_buffer_mem_used": Performance.get_monitor(Performance.RENDER_BUFFER_MEM_USED),
|
|
721
726
|
}
|
|
722
727
|
|
|
723
728
|
if metrics.is_empty():
|
|
@@ -46,6 +46,9 @@ const IMPORTER_VERSIONS: Dictionary[String, int] = {"scene": 1, "animation_libra
|
|
|
46
46
|
## The VRAM compression formats a project can ask for, each behind its
|
|
47
47
|
## `rendering/textures/vram_compression/import_<format>` setting.
|
|
48
48
|
const VRAM_FORMATS: Array[String] = ["s3tc_bptc", "etc2_astc"]
|
|
49
|
+
## Options keyed by OpenType tags, by the importer that holds them. The engine keys them by the tag as
|
|
50
|
+
## an int, which a caller can also name, as "liga" for ligatures.
|
|
51
|
+
const OPENTYPE_OPTIONS: Dictionary[String, Array] = {"font_data_dynamic": ["opentype_features"]}
|
|
49
52
|
const EDITOR_DIRECTORY: String = "res://.godot/editor"
|
|
50
53
|
const EDITOR_CACHE_PREFIX: String = "filesystem_cache"
|
|
51
54
|
|
|
@@ -177,6 +180,7 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
177
180
|
return _log.failure("Failed to parse import file: " + str(err))
|
|
178
181
|
|
|
179
182
|
# Every option checked before any is written, so a refused one leaves the sidecar as it was.
|
|
183
|
+
var importer: String = str(config.get_value("remap", "importer", ""))
|
|
180
184
|
var fitted_values: Dictionary = {}
|
|
181
185
|
var refused: Array[String] = []
|
|
182
186
|
for key: Variant in options:
|
|
@@ -184,6 +188,13 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
184
188
|
var held: Variant = config.get_value("params", name, null)
|
|
185
189
|
var given: Variant = _values.deserialize_value(options[key])
|
|
186
190
|
var value: Variant = _as_option(_values.fitted(options[key], typeof(held)), held)
|
|
191
|
+
if value is Dictionary:
|
|
192
|
+
var entries: Dictionary = value
|
|
193
|
+
var keyed: Dictionary = _keyed_as_option(name, entries, held, importer)
|
|
194
|
+
if keyed.has("refused"):
|
|
195
|
+
refused.append(str(keyed["refused"]))
|
|
196
|
+
continue
|
|
197
|
+
value = keyed["value"]
|
|
187
198
|
# A number the option cannot hold whole is refused rather than truncated: 2.5 fitted to an
|
|
188
199
|
# enum option became 2, which is another setting.
|
|
189
200
|
var lossy: bool = (
|
|
@@ -227,6 +238,87 @@ static func _as_option(given: Variant, held: Variant) -> Variant:
|
|
|
227
238
|
return given
|
|
228
239
|
|
|
229
240
|
|
|
241
|
+
## [param given] with the keys and values a dictionary option holds as ints made ints again.
|
|
242
|
+
##
|
|
243
|
+
## JSON names every key as a string and every number as a float, so a font's opentype_features was
|
|
244
|
+
## written as {"1818847073": 0.0}. The importer hands that to FontFile.opentype_feature_overrides,
|
|
245
|
+
## which keys features by their tag as an int, so the call reported success and the font kept its
|
|
246
|
+
## ligatures. A key is an int where the importer keys the option by OpenType tags, or where every key
|
|
247
|
+
## the option holds already is one; a value is an int where every value held is, or for a tag.
|
|
248
|
+
## Answers the dictionary under value, or why it cannot be written under refused.
|
|
249
|
+
static func _keyed_as_option(name: String, given: Dictionary, held: Variant, importer: String) -> Dictionary:
|
|
250
|
+
var tag_options: Array = OPENTYPE_OPTIONS.get(importer, [])
|
|
251
|
+
var tagged: bool = name in tag_options
|
|
252
|
+
var numbered_keys: bool = tagged or _all_ints(held, true)
|
|
253
|
+
var whole_values: bool = tagged or _all_ints(held, false)
|
|
254
|
+
if not numbered_keys and not whole_values:
|
|
255
|
+
return {"value": given}
|
|
256
|
+
var keyed: Dictionary = {}
|
|
257
|
+
for key: Variant in given:
|
|
258
|
+
var as_key: Variant = key
|
|
259
|
+
if numbered_keys:
|
|
260
|
+
as_key = _int_key(key, tagged)
|
|
261
|
+
if as_key == null:
|
|
262
|
+
var wanted: String = (
|
|
263
|
+
'OpenType tags, by name such as "liga" or as the number TextServer.name_to_tag gives'
|
|
264
|
+
if tagged
|
|
265
|
+
else "ints"
|
|
266
|
+
)
|
|
267
|
+
return {
|
|
268
|
+
"refused": "%s is keyed by %s, and %s is not one" % [name, wanted, JSON.stringify(key)]
|
|
269
|
+
}
|
|
270
|
+
var value: Variant = given[key]
|
|
271
|
+
if whole_values and value is float:
|
|
272
|
+
var number: float = value
|
|
273
|
+
if number == floorf(number):
|
|
274
|
+
value = int(number)
|
|
275
|
+
keyed[as_key] = value
|
|
276
|
+
return {"value": keyed}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
## [param key] as an int key, or null when it is not one: a whole number, written as a number or as
|
|
280
|
+
## a string of digits, or where [param tagged], an OpenType tag's four characters.
|
|
281
|
+
static func _int_key(key: Variant, tagged: bool) -> Variant:
|
|
282
|
+
if key is int:
|
|
283
|
+
return key
|
|
284
|
+
if key is float:
|
|
285
|
+
var number: float = key
|
|
286
|
+
if number != floorf(number):
|
|
287
|
+
return null
|
|
288
|
+
return int(number)
|
|
289
|
+
if not key is String:
|
|
290
|
+
return null
|
|
291
|
+
var text: String = key
|
|
292
|
+
if text.is_valid_int():
|
|
293
|
+
return text.to_int()
|
|
294
|
+
# Exactly four printable characters, since the engine reads a longer name by its first four and
|
|
295
|
+
# "ligatures" would quietly become "liga".
|
|
296
|
+
if tagged and text.length() == 4 and _printable(text):
|
|
297
|
+
return TextServerManager.get_primary_interface().name_to_tag(text)
|
|
298
|
+
return null
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
static func _printable(text: String) -> bool:
|
|
302
|
+
for i: int in text.length():
|
|
303
|
+
var code: int = text.unicode_at(i)
|
|
304
|
+
if code < 0x20 or code > 0x7e:
|
|
305
|
+
return false
|
|
306
|
+
return true
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
## Whether [param held] is a dictionary that is not empty and whose keys, or values, are all ints.
|
|
310
|
+
static func _all_ints(held: Variant, keys: bool) -> bool:
|
|
311
|
+
if not held is Dictionary:
|
|
312
|
+
return false
|
|
313
|
+
var dictionary: Dictionary = held
|
|
314
|
+
if dictionary.is_empty():
|
|
315
|
+
return false
|
|
316
|
+
for item: Variant in dictionary.keys() if keys else dictionary.values():
|
|
317
|
+
if not item is int:
|
|
318
|
+
return false
|
|
319
|
+
return true
|
|
320
|
+
|
|
321
|
+
|
|
230
322
|
# The names of the project's export presets, in the order export_presets.cfg holds them.
|
|
231
323
|
static func _preset_names() -> Array[String]:
|
|
232
324
|
var names: Array[String] = []
|
|
@@ -2,6 +2,7 @@ extends RefCounted
|
|
|
2
2
|
|
|
3
3
|
const Read = preload("reading.gd")
|
|
4
4
|
const Log = preload("logger.gd")
|
|
5
|
+
const ProjectFile = preload("project_file.gd")
|
|
5
6
|
|
|
6
7
|
# The key names a caller may write, and what the engine calls them.
|
|
7
8
|
const KEY_NAMES: Dictionary = {
|
|
@@ -172,7 +173,7 @@ func add_input_action(params: Dictionary) -> Dictionary:
|
|
|
172
173
|
# Through ProjectSettings so the file is saved the way the editor saves it, header and
|
|
173
174
|
# every other line kept; a ConfigFile of project.godot drops the comments on the way out.
|
|
174
175
|
ProjectSettings.set_setting("input/" + action_name, action)
|
|
175
|
-
var err: Error =
|
|
176
|
+
var err: Error = ProjectFile.save()
|
|
176
177
|
if err != OK:
|
|
177
178
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
178
179
|
|
|
@@ -205,7 +206,7 @@ func remove_input_action(params: Dictionary) -> Dictionary:
|
|
|
205
206
|
)
|
|
206
207
|
)
|
|
207
208
|
ProjectSettings.set_setting(setting, null)
|
|
208
|
-
var err: Error =
|
|
209
|
+
var err: Error = ProjectFile.save()
|
|
209
210
|
if err != OK:
|
|
210
211
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
211
212
|
return {"action_name": action_name, "removed": true}
|
|
@@ -2,6 +2,7 @@ extends RefCounted
|
|
|
2
2
|
|
|
3
3
|
const Patterns = preload("patterns.gd")
|
|
4
4
|
const Log = preload("logger.gd")
|
|
5
|
+
const ProjectFile = preload("project_file.gd")
|
|
5
6
|
|
|
6
7
|
var _log: Log
|
|
7
8
|
|
|
@@ -171,11 +172,11 @@ static func _names_of(paths: Array[String]) -> Array[String]:
|
|
|
171
172
|
func _save_enabled_paths(paths: Array[String]) -> Error:
|
|
172
173
|
if paths.is_empty():
|
|
173
174
|
ProjectSettings.set_setting("editor_plugins/enabled", null)
|
|
174
|
-
return
|
|
175
|
+
return ProjectFile.save()
|
|
175
176
|
|
|
176
177
|
# Kept in an Array[String] and converted here, because appending to a packed array answers with
|
|
177
178
|
# whether it worked and a project holding return_value_discarded at error level will not compile
|
|
178
179
|
# a script that drops that answer. The conversion is what matters: an Array[String] handed to
|
|
179
180
|
# set_setting is written as Array[String]([...]), which the editor does not read as a plugin list.
|
|
180
181
|
ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(paths))
|
|
181
|
-
return
|
|
182
|
+
return ProjectFile.save()
|
|
@@ -2,6 +2,7 @@ extends RefCounted
|
|
|
2
2
|
|
|
3
3
|
const Read = preload("reading.gd")
|
|
4
4
|
const Log = preload("logger.gd")
|
|
5
|
+
const ProjectFile = preload("project_file.gd")
|
|
5
6
|
const Serialisation = preload("serialisation.gd")
|
|
6
7
|
|
|
7
8
|
var _log: Log
|
|
@@ -124,12 +125,7 @@ func set_project_setting(params: Dictionary) -> Dictionary:
|
|
|
124
125
|
)
|
|
125
126
|
final_value = type_convert(final_value, wanted)
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
var err: Error = ProjectSettings.save()
|
|
129
|
-
if err != OK:
|
|
130
|
-
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
131
|
-
|
|
132
|
-
return {
|
|
128
|
+
var answer: Dictionary = {
|
|
133
129
|
"setting_path": setting_path,
|
|
134
130
|
"old_value": _values.serialize_value(old_value) if had_value else null,
|
|
135
131
|
"new_value": _values.serialize_value(final_value),
|
|
@@ -137,6 +133,18 @@ func set_project_setting(params: Dictionary) -> Dictionary:
|
|
|
137
133
|
"was_default": was_default,
|
|
138
134
|
"saved": true,
|
|
139
135
|
}
|
|
136
|
+
# The same value in the same type is no change, and the file is left alone: a save rewrites it
|
|
137
|
+
# whole. The type is compared as well, so an int setting the file holds as 50.0 is still put right.
|
|
138
|
+
if had_value and typeof(old_value) == typeof(final_value) and old_value == final_value:
|
|
139
|
+
answer["saved"] = false
|
|
140
|
+
answer["unchanged"] = true
|
|
141
|
+
return answer
|
|
142
|
+
|
|
143
|
+
ProjectSettings.set_setting(setting_path, final_value)
|
|
144
|
+
var err: Error = ProjectFile.save()
|
|
145
|
+
if err != OK:
|
|
146
|
+
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
147
|
+
return answer
|
|
140
148
|
|
|
141
149
|
|
|
142
150
|
# The type a setting wants, or TYPE_NIL where the engine names none and there is nothing to go on.
|
|
@@ -201,7 +209,7 @@ func add_autoload(params: Dictionary) -> Dictionary:
|
|
|
201
209
|
# "Global Variable" column. Without it the node still comes up under the root; only the name
|
|
202
210
|
# stops resolving.
|
|
203
211
|
ProjectSettings.set_setting(setting, ("*" if global else "") + path)
|
|
204
|
-
var err: Error =
|
|
212
|
+
var err: Error = ProjectFile.save()
|
|
205
213
|
if err != OK:
|
|
206
214
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
207
215
|
|
|
@@ -226,7 +234,7 @@ func remove_autoload(params: Dictionary) -> Dictionary:
|
|
|
226
234
|
|
|
227
235
|
var old_value: String = str(ProjectSettings.get_setting(setting))
|
|
228
236
|
ProjectSettings.set_setting(setting, null)
|
|
229
|
-
var err: Error =
|
|
237
|
+
var err: Error = ProjectFile.save()
|
|
230
238
|
if err != OK:
|
|
231
239
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
232
240
|
|
|
@@ -275,7 +283,7 @@ func set_main_scene(params: Dictionary) -> Dictionary:
|
|
|
275
283
|
var old_main_scene: String = str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
|
276
284
|
|
|
277
285
|
ProjectSettings.set_setting("application/run/main_scene", scene_path)
|
|
278
|
-
var err: Error =
|
|
286
|
+
var err: Error = ProjectFile.save()
|
|
279
287
|
|
|
280
288
|
if err != OK:
|
|
281
289
|
return _log.failure("Failed to save project settings: " + str(err))
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
## project.godot written through the engine's own save, in the order the file already held.
|
|
4
|
+
##
|
|
5
|
+
## The engine writes each section's keys in the order it registered them, and an engine running a
|
|
6
|
+
## script registers them in another order than the editor does, so a one-setting change moved lines
|
|
7
|
+
## nobody touched across several sections and a commit of it read as a rewrite. The save is still the
|
|
8
|
+
## engine's, for what only it gets right: the header, each value's text and dropping a key that is
|
|
9
|
+
## back at its default. Only the order is taken from the file as it was.
|
|
10
|
+
|
|
11
|
+
const PATH: String = "res://project.godot"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Saves the project's settings, keeping the sections and keys already in the file where they were.
|
|
15
|
+
## A key or a section new to the file goes where the engine put it relative to its neighbours.
|
|
16
|
+
static func save() -> Error:
|
|
17
|
+
var before: String = FileAccess.get_file_as_string(PATH)
|
|
18
|
+
var err: Error = ProjectSettings.save()
|
|
19
|
+
if err != OK or before.is_empty():
|
|
20
|
+
return err
|
|
21
|
+
var written: String = FileAccess.get_file_as_string(PATH)
|
|
22
|
+
var kept: String = in_order_of(before, written)
|
|
23
|
+
if kept == written:
|
|
24
|
+
return OK
|
|
25
|
+
var file: FileAccess = FileAccess.open(PATH, FileAccess.WRITE)
|
|
26
|
+
if file == null:
|
|
27
|
+
return FileAccess.get_open_error()
|
|
28
|
+
if not file.store_string(kept):
|
|
29
|
+
return file.get_error()
|
|
30
|
+
file.close()
|
|
31
|
+
return OK
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## [param written] with its sections and their keys put in the order [param before] has them. Every
|
|
35
|
+
## line of the result is a line of [param written], so nothing the engine wrote is lost or changed.
|
|
36
|
+
static func in_order_of(before: String, written: String) -> String:
|
|
37
|
+
var old: Dictionary = _parsed(before)
|
|
38
|
+
var fresh: Dictionary = _parsed(written)
|
|
39
|
+
var old_sections: Array[Dictionary] = old["sections"]
|
|
40
|
+
var fresh_sections: Array[Dictionary] = fresh["sections"]
|
|
41
|
+
|
|
42
|
+
var old_names: Array[String] = []
|
|
43
|
+
var old_keys: Dictionary[String, Array] = {}
|
|
44
|
+
for section: Dictionary in old_sections:
|
|
45
|
+
var name: String = section["name"]
|
|
46
|
+
old_names.append(name)
|
|
47
|
+
var keys: Array[String] = []
|
|
48
|
+
for entry: Dictionary in section["entries"]:
|
|
49
|
+
keys.append(str(entry["key"]))
|
|
50
|
+
old_keys[name] = keys
|
|
51
|
+
|
|
52
|
+
var by_name: Dictionary[String, Dictionary] = {}
|
|
53
|
+
var fresh_names: Array[String] = []
|
|
54
|
+
for section: Dictionary in fresh_sections:
|
|
55
|
+
var name: String = section["name"]
|
|
56
|
+
by_name[name] = section
|
|
57
|
+
fresh_names.append(name)
|
|
58
|
+
|
|
59
|
+
var text: String = fresh["preamble"]
|
|
60
|
+
var at: int = 0
|
|
61
|
+
for name: String in _merged(old_names, fresh_names):
|
|
62
|
+
var section: Dictionary = by_name[name]
|
|
63
|
+
var entries: Array[Dictionary] = section["entries"]
|
|
64
|
+
var entry_of: Dictionary[String, String] = {}
|
|
65
|
+
var fresh_keys: Array[String] = []
|
|
66
|
+
for entry: Dictionary in entries:
|
|
67
|
+
var key: String = entry["key"]
|
|
68
|
+
entry_of[key] = str(entry["text"])
|
|
69
|
+
fresh_keys.append(key)
|
|
70
|
+
var known: Array = old_keys.get(name, [])
|
|
71
|
+
var held: Array[String] = []
|
|
72
|
+
held.assign(known)
|
|
73
|
+
text += str(section["head"])
|
|
74
|
+
for key: String in _merged(held, fresh_keys):
|
|
75
|
+
text += entry_of[key]
|
|
76
|
+
# The blank lines closing a section belong to its place in the file rather than to the
|
|
77
|
+
# section, so the last one keeps the ending the engine gave the file wherever it moved from.
|
|
78
|
+
var placed: Dictionary = fresh_sections[at]
|
|
79
|
+
text += str(placed["tail"])
|
|
80
|
+
at += 1
|
|
81
|
+
return text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
## The names of [param wanted] in the order [param held] lists them, those [param held] lacks each
|
|
85
|
+
## placed after the name [param wanted] puts before it. Names [param wanted] lacks are left out.
|
|
86
|
+
static func _merged(held: Array[String], wanted: Array[String]) -> Array[String]:
|
|
87
|
+
var order: Array[String] = []
|
|
88
|
+
for name: String in held:
|
|
89
|
+
if name in wanted and not name in order:
|
|
90
|
+
order.append(name)
|
|
91
|
+
var after: int = -1
|
|
92
|
+
for name: String in wanted:
|
|
93
|
+
var placed: int = order.find(name)
|
|
94
|
+
if placed == -1:
|
|
95
|
+
after += 1
|
|
96
|
+
# Not checked: a position inside the array cannot be refused.
|
|
97
|
+
var _inserted: int = order.insert(after, name)
|
|
98
|
+
else:
|
|
99
|
+
after = placed
|
|
100
|
+
return order
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
## [param text] as a preamble (the header and anything before the first section) and its sections,
|
|
104
|
+
## each with its header line and the blank lines after it, its entries, and the blank lines closing it.
|
|
105
|
+
static func _parsed(text: String) -> Dictionary:
|
|
106
|
+
var preamble: String = ""
|
|
107
|
+
var sections: Array[Dictionary] = []
|
|
108
|
+
var current: Dictionary = {}
|
|
109
|
+
var body: Array[String] = []
|
|
110
|
+
for line: String in _logical_lines(text):
|
|
111
|
+
var stripped: String = line.strip_edges()
|
|
112
|
+
if stripped.begins_with("[") and stripped.ends_with("]"):
|
|
113
|
+
if not current.is_empty():
|
|
114
|
+
_close(current, body)
|
|
115
|
+
sections.append(current)
|
|
116
|
+
current = {"name": stripped.substr(1, stripped.length() - 2), "head": line}
|
|
117
|
+
body = []
|
|
118
|
+
elif current.is_empty():
|
|
119
|
+
preamble += line
|
|
120
|
+
elif body.is_empty() and stripped.is_empty():
|
|
121
|
+
current["head"] = str(current["head"]) + line
|
|
122
|
+
else:
|
|
123
|
+
body.append(line)
|
|
124
|
+
if not current.is_empty():
|
|
125
|
+
_close(current, body)
|
|
126
|
+
sections.append(current)
|
|
127
|
+
return {"preamble": preamble, "sections": sections}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
## Fills in [param section]'s entries and closing blank lines from the lines after its head. A blank
|
|
131
|
+
## or comment line between two entries stays with the entry before it.
|
|
132
|
+
static func _close(section: Dictionary, body: Array[String]) -> void:
|
|
133
|
+
var tail: String = ""
|
|
134
|
+
while not body.is_empty() and body[body.size() - 1].strip_edges().is_empty():
|
|
135
|
+
tail = body[body.size() - 1] + tail
|
|
136
|
+
body.remove_at(body.size() - 1)
|
|
137
|
+
var entries: Array[Dictionary] = []
|
|
138
|
+
for line: String in body:
|
|
139
|
+
var stripped: String = line.strip_edges()
|
|
140
|
+
if entries.is_empty() or not (stripped.is_empty() or stripped.begins_with(";")):
|
|
141
|
+
entries.append({"key": _key_of(line), "text": line})
|
|
142
|
+
else:
|
|
143
|
+
var last: Dictionary = entries[entries.size() - 1]
|
|
144
|
+
last["text"] = str(last["text"]) + line
|
|
145
|
+
section["entries"] = entries
|
|
146
|
+
section["tail"] = tail
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
## The key an entry sets: everything before its first `=` outside quotes, since a key with spaces or
|
|
150
|
+
## symbols in it is written quoted.
|
|
151
|
+
static func _key_of(line: String) -> String:
|
|
152
|
+
var quoted: bool = false
|
|
153
|
+
for i: int in line.length():
|
|
154
|
+
var c: String = line[i]
|
|
155
|
+
if c == '"' and (i == 0 or line[i - 1] != "\\"):
|
|
156
|
+
quoted = not quoted
|
|
157
|
+
elif c == "=" and not quoted:
|
|
158
|
+
return line.substr(0, i).strip_edges()
|
|
159
|
+
return line.strip_edges()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
## [param text] split into lines that each end where an entry does: a value holding a dictionary, an
|
|
163
|
+
## array or a string runs over several lines, and splitting it there would sort its halves apart.
|
|
164
|
+
## Each piece keeps its line ending, so joining them gives the text back.
|
|
165
|
+
static func _logical_lines(text: String) -> Array[String]:
|
|
166
|
+
var lines: Array[String] = []
|
|
167
|
+
var start: int = 0
|
|
168
|
+
var depth: int = 0
|
|
169
|
+
var quoted: bool = false
|
|
170
|
+
var escaped: bool = false
|
|
171
|
+
var comment: bool = false
|
|
172
|
+
for i: int in text.length():
|
|
173
|
+
var c: String = text[i]
|
|
174
|
+
if i == start:
|
|
175
|
+
comment = c == ";"
|
|
176
|
+
if comment:
|
|
177
|
+
if c == "\n":
|
|
178
|
+
lines.append(text.substr(start, i + 1 - start))
|
|
179
|
+
start = i + 1
|
|
180
|
+
continue
|
|
181
|
+
if quoted:
|
|
182
|
+
if escaped:
|
|
183
|
+
escaped = false
|
|
184
|
+
elif c == "\\":
|
|
185
|
+
escaped = true
|
|
186
|
+
elif c == '"':
|
|
187
|
+
quoted = false
|
|
188
|
+
continue
|
|
189
|
+
match c:
|
|
190
|
+
'"':
|
|
191
|
+
quoted = true
|
|
192
|
+
"[", "(", "{":
|
|
193
|
+
depth += 1
|
|
194
|
+
"]", ")", "}":
|
|
195
|
+
depth = maxi(depth - 1, 0)
|
|
196
|
+
"\n":
|
|
197
|
+
if depth == 0:
|
|
198
|
+
lines.append(text.substr(start, i + 1 - start))
|
|
199
|
+
start = i + 1
|
|
200
|
+
if start < text.length():
|
|
201
|
+
lines.append(text.substr(start))
|
|
202
|
+
return lines
|