gdharness 1.3.68 → 1.3.70

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 CHANGED
@@ -21188,7 +21188,7 @@ var init_tool_definitions = __esm(() => {
21188
21188
  name: "scene_tree",
21189
21189
  title: "Scene tree",
21190
21190
  effect: "read",
21191
- description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. type is the engine class, and a node whose script declares a class_name names it under scriptClass, as scene_node get does. A node that instances another scene names that file under instanceOf, and the nodes listed under it come from that file rather than this one. Its editableChildren says whether this scene, or a scene it inherits, keeps values of its own for them (scene_node set editableChildren). The root of a scene inheriting another names the scene it inherits under inherits, and every node that scene or one further down makes names its file under inheritedFrom. This file holds only the values it changes on them. A node in any group answers groups, sorted, from this file and every scene it comes from. Needs no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without making its nodes, so no script of theirs runs (autoloads still start once it has answered, as in every headless call). For the nodes of a game that is running, runtime_inspect tree.",
21191
+ description: "The nodes of a scene file: names, classes and hierarchy, with properties when asked. type is the engine class, and a node whose script declares a class_name names it under scriptClass, as scene_node get does. A node that instances another scene names that file under instanceOf, and the nodes listed under it come from that file rather than this one. Its editableChildren says whether this scene, or a scene it inherits, keeps values of its own for them (scene_node set editableChildren). One saved as a placeholder answers placeholder true, with InstancePlaceholder as its type, as a game loads it (scene_node set placeholder). The root of a scene inheriting another names the scene it inherits under inherits, and every node that scene or one further down makes names its file under inheritedFrom. This file holds only the values it changes on them. A node in any group answers groups, sorted, from this file and every scene it comes from. Needs no editor: one serving the project answers and says whether it holds unsaved changes to the scene, and with none a headless engine reads the file without making its nodes, so no script of theirs runs (autoloads still start once it has answered, as in every headless call). For the nodes of a game that is running, runtime_inspect tree.",
21192
21192
  parameters: {
21193
21193
  projectPath: PROJECT_PATH,
21194
21194
  scenePath: SCENE_PATH,
@@ -21256,6 +21256,11 @@ var init_tool_definitions = __esm(() => {
21256
21256
  ops: ["set"],
21257
21257
  description: "set: on an instance of another scene, whether its children are editable here, as the editor's Editable Children makes them. True lets scene_node set give the nodes inside that instance values of this scene's own, which no other instance shares. An instance nested inside another takes it once the one around it has editable children. False drops every value this scene keeps for them, and every node it added below one of them. The answer names the nodes whose values went under droppedValues (they stay, with the instance's own values) and the added nodes that went under droppedNodes. A node added straight under the instance stays. The answer says editableChildren as the file now holds it."
21258
21258
  },
21259
+ placeholder: {
21260
+ type: "boolean",
21261
+ ops: ["add", "set"],
21262
+ description: "add, with instancePath, or set, on an instance this scene places: whether it is saved as the editor's Load as Placeholder saves it, as `instance_placeholder` in the file. A game then gets an InstancePlaceholder there and loads the scene itself, while the editor still shows it. A placeholder loads without its children, so it never has editableChildren too. Passing editableChildren false with placeholder true drops the children's saved values. The answer says placeholder as the file now holds it."
21263
+ },
21259
21264
  addGroups: {
21260
21265
  type: "array",
21261
21266
  items: { type: "string" },
@@ -21310,12 +21315,12 @@ var init_tool_definitions = __esm(() => {
21310
21315
  },
21311
21316
  get: {
21312
21317
  effect: "read",
21313
- summary: "read a node's class, path, properties and where its values come from, and a TileMapLayer's or TileMap's cells as set_tilemap_cells takes them, the first 1000 with cellCount for all. An instance of another scene also answers instanceOf and editableChildren, as scene_tree does",
21318
+ summary: "read a node's class, path, properties and where its values come from, and a TileMapLayer's or TileMap's cells as set_tilemap_cells takes them, the first 1000 with cellCount for all. An instance of another scene also answers instanceOf, editableChildren and placeholder",
21314
21319
  requires: ["nodePath"]
21315
21320
  },
21316
21321
  set: {
21317
21322
  effect: "destructive",
21318
- summary: "set properties on a node, make the children of an instance editable, or put it in groups or take it out of them, or any of them together",
21323
+ summary: "set properties on a node, make the children of an instance editable or the instance a placeholder, or put it in groups or take it out of them, or any of them together",
21319
21324
  requires: ["nodePath"]
21320
21325
  },
21321
21326
  duplicate: {
@@ -21972,7 +21977,7 @@ var init_tool_definitions = __esm(() => {
21972
21977
  },
21973
21978
  wait: {
21974
21979
  effect: "read",
21975
- summary: "wait for the run to end, or to be held by its debugger at a breakpoint, a pause or a step, then answer as editor_output does",
21980
+ summary: "wait for the run to end, to be held by its debugger at a breakpoint, a pause or a step, or to have crashed, then answer as editor_output does",
21976
21981
  requires: []
21977
21982
  },
21978
21983
  profile: {
@@ -59398,6 +59403,8 @@ ${error.text}`;
59398
59403
  const cancelled = callSignal();
59399
59404
  let held = false;
59400
59405
  let holdAsked = 0;
59406
+ let crashedAt;
59407
+ let crashed = false;
59401
59408
  while (cancelled?.aborted !== true && await this.runStillGoing(run) && Date.now() < until) {
59402
59409
  this.drainEditorOutput(run);
59403
59410
  this.drainTranscript(run);
@@ -59407,13 +59414,19 @@ ${error.text}`;
59407
59414
  held = true;
59408
59415
  break;
59409
59416
  }
59417
+ crashedAt ??= run.log.crashReport() === undefined ? undefined : Date.now();
59418
+ }
59419
+ if (crashedAt !== undefined && Date.now() - crashedAt >= CRASH_EXIT_GRACE_MS) {
59420
+ crashed = true;
59421
+ break;
59410
59422
  }
59411
59423
  await delay10(RUN_POLL_MS);
59412
59424
  }
59413
59425
  if (!await this.runStillGoing(run)) {
59414
59426
  return await this.handleGetDebugOutput(args);
59415
59427
  }
59416
- return await this.handleGetDebugOutput(args, held ? { heldAfterMs: Date.now() - began } : { ranOutMs: budgetMs });
59428
+ const after = Date.now() - began;
59429
+ return await this.handleGetDebugOutput(args, held ? { heldAfterMs: after } : crashed ? { crashedAfterMs: after } : { ranOutMs: budgetMs });
59417
59430
  }
59418
59431
  async heldByItsDebugger(run) {
59419
59432
  if (!run.throughEditor) {
@@ -59611,6 +59624,9 @@ ${error.text}`;
59611
59624
  if (waited !== undefined && "heldAfterMs" in waited) {
59612
59625
  notes.push(`The wait answered after ${waited.heldAfterMs}ms because the game is held by its debugger, where heldAt says, and a held game does not end: debug_control continue lets it go, and editor_run wait then waits on.`);
59613
59626
  }
59627
+ if (waited !== undefined && "crashedAfterMs" in waited) {
59628
+ notes.push(`The wait answered after ${waited.crashedAfterMs}ms because the game crashed and printed its crash report, under entries. Its process has not ended yet, since the system can hold a crashed process while it reports the crash, as Windows Error Reporting does. Nothing more will come from the game. editor_run stop ends it now.`);
59629
+ }
59614
59630
  const restarts = run.restartedFrom ?? [];
59615
59631
  if (restarts.length > 0) {
59616
59632
  const chain = [...restarts.map((one) => one.pid), run.pid].join(" into ");
@@ -61552,7 +61568,7 @@ async function runGodotServer() {
61552
61568
  const server = new GodotServer;
61553
61569
  await server.run();
61554
61570
  }
61555
- var TYPE_ARGUMENTS, UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, FAILED_BUDGET = 1e6, ANSWER_BUDGET = 4000000, __dirname2, PARENT_ASKED_AFTER_MS = 5000, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, HIDDEN_WITH_SOUND = "A hidden editor plays no sound, so silent: false is refused with it.", HIDDEN_WITH_SOUND_FIX = "Pass hidden: false to hear the editor, or leave silent out", SLOWEST_FRAME_RATE = 20, ALONGSIDE_NOTE = "Another call on this game was running while this one was: calls sent together run together and are answered as each finishes, so this answer is not from after the other. Send a call meant to come after another once that one has answered.", OWN_DEBUGGER = " Its debugger is the keeper holding it rather than the editor, so the debug_* tools answer for it all the same: breakpoints stop it, and debug_control pause breaks into it wherever it is, which the editor offers no way to do.", PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, KEEPER_DEBUG_MS, EXIT_REPORTED_WITHIN_MS = 2000, SUCCESSOR_SOUGHT_WITHIN_MS = 20000, CLOSE_ASK_MS = 3000, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_SETTLED_MS = 500, SLOW_ANSWER_MS = 1000, REIMPORT_TIMEOUT_MS = 300000, OPS_ELSEWHERE, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_TIMEOUT_MS = 600000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", LOOPING_AFTER = 100, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, HOLD_ASKED_EVERY_MS = 1000, PATH_SOLUTIONS, DESTINATION_ARGUMENTS, OUTPUT_PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", PROFILE_FUNCTIONS = 30, PROFILER_ON_MS = 3000, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, MOST_PRINTED_LINES = 50, MOST_UNDECLARED_ERRORS = 20, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", RAISED_WARNING = " (Warning treated as error.)", PARSE_ERROR2 = "Parse Error: ", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
61571
+ var TYPE_ARGUMENTS, UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, FAILED_BUDGET = 1e6, ANSWER_BUDGET = 4000000, __dirname2, PARENT_ASKED_AFTER_MS = 5000, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, HIDDEN_WITH_SOUND = "A hidden editor plays no sound, so silent: false is refused with it.", HIDDEN_WITH_SOUND_FIX = "Pass hidden: false to hear the editor, or leave silent out", SLOWEST_FRAME_RATE = 20, ALONGSIDE_NOTE = "Another call on this game was running while this one was: calls sent together run together and are answered as each finishes, so this answer is not from after the other. Send a call meant to come after another once that one has answered.", OWN_DEBUGGER = " Its debugger is the keeper holding it rather than the editor, so the debug_* tools answer for it all the same: breakpoints stop it, and debug_control pause breaks into it wherever it is, which the editor offers no way to do.", PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, KEEPER_DEBUG_MS, EXIT_REPORTED_WITHIN_MS = 2000, SUCCESSOR_SOUGHT_WITHIN_MS = 20000, CLOSE_ASK_MS = 3000, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_SETTLED_MS = 500, SLOW_ANSWER_MS = 1000, REIMPORT_TIMEOUT_MS = 300000, OPS_ELSEWHERE, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_TIMEOUT_MS = 600000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", LOOPING_AFTER = 100, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, HOLD_ASKED_EVERY_MS = 1000, CRASH_EXIT_GRACE_MS = 1e4, PATH_SOLUTIONS, DESTINATION_ARGUMENTS, OUTPUT_PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", PROFILE_FUNCTIONS = 30, PROFILER_ON_MS = 3000, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, MOST_PRINTED_LINES = 50, MOST_UNDECLARED_ERRORS = 20, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", RAISED_WARNING = " (Warning treated as error.)", PARSE_ERROR2 = "Parse Error: ", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
61556
61572
  var init_server = __esm(() => {
61557
61573
  init_dist2();
61558
61574
  init_stdio();
@@ -186,6 +186,7 @@ static func _lay(
186
186
  elif state.is_node_instance_placeholder(index):
187
187
  nodes[path] = _made("InstancePlaceholder")
188
188
  nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
189
+ nodes[path]["placeholder"] = true
189
190
  elif str(state.get_node_type(index)).is_empty():
190
191
  # An entry for a node of an instance that no longer has it, which the engine skips
191
192
  # too.
@@ -367,6 +368,8 @@ static func _entry(
367
368
  data["instanceOf"] = node["instanceOf"]
368
369
  if node.has("editableChildren"):
369
370
  data["editableChildren"] = node["editableChildren"]
371
+ if node.has("placeholder"):
372
+ data["placeholder"] = true
370
373
  # An inherited scene's file holds only what it changes, so the read says what it inherits and
371
374
  # which nodes come from there, as the editor greys them.
372
375
  if node.has("inherits"):
@@ -79,6 +79,10 @@ var _copied_from: Dictionary = {}
79
79
  var _reached: Dictionary = {}
80
80
  ## The file as it was opened, to compare its embedded resources with what the write saves.
81
81
  var _text: String = ""
82
+ ## The unique_id each placeholder put back as its instance had in the file, by the instance's object
83
+ ## id. Scripts cannot set one, the engine gives the instance a new one as it packs it, and a scene
84
+ ## inheriting this one finds a moved node by it.
85
+ var _held_ids: Dictionary = {}
82
86
  ## The engine properties a put back did not take on a node, as "id:name": the engine derives them
83
87
  ## from where the node is, as it keeps layout_mode at Container under a container, and stores the
84
88
  ## same from an editor.
@@ -124,6 +128,8 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
124
128
  return { "ok": false, "error": "Failed to load %s as a scene" % path }
125
129
  if root == null:
126
130
  return { "ok": false, "error": "Failed to instantiate " + path }
131
+ if not Engine.is_editor_hint():
132
+ _unhold_placeholders()
127
133
  _state = packed.get_state()
128
134
  _base_paths = makers(_state)
129
135
  if for_writing:
@@ -145,6 +151,133 @@ func open(scene_path: String, for_writing: bool) -> Dictionary:
145
151
  return {}
146
152
 
147
153
 
154
+ ## Puts back each placeholder this scene places as the instance itself, marked, as an editor opens
155
+ ## it. An engine with no editor opens one as an InstancePlaceholder even in an edit state, and the
156
+ ## scene then packs it as a node of that class with every value it holds, so a save lost the mark.
157
+ ## The instance takes the placeholder's place, values, groups, connections and the node values
158
+ ## naming it. One whose scene does not instance stays as it was.
159
+ func _unhold_placeholders() -> void:
160
+ var held: Array[InstancePlaceholder] = []
161
+ _placeholders_under(root, held)
162
+ var as_text: bool = path.ends_with(".tscn") and not held.is_empty()
163
+ var text: String = FileAccess.get_file_as_string(path) if as_text else ""
164
+ for one: InstancePlaceholder in held:
165
+ var packed: PackedScene = (
166
+ ResourceLoader.load(one.get_instance_path(), "PackedScene") as PackedScene
167
+ )
168
+ var instance: Node = (
169
+ packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) if packed != null else null
170
+ )
171
+ if instance == null:
172
+ continue
173
+ instance.name = one.name
174
+ var values: Dictionary = one.get_stored_values()
175
+ for key: Variant in values:
176
+ instance.set(str(key), values[key])
177
+ var filed_id: String = _filed_id(text, _header_of(root.get_path_to(one)))
178
+ # replace_by moves the place and the groups.
179
+ one.replace_by(instance, true)
180
+ instance.owner = root
181
+ instance.scene_file_path = one.get_instance_path()
182
+ instance.set_scene_instance_load_placeholder(true)
183
+ if not filed_id.is_empty():
184
+ _held_ids[instance.get_instance_id()] = filed_id
185
+ _repointed(root, one, instance)
186
+ one.free()
187
+
188
+
189
+ ## How a text scene opens the entry of the node at [param at], a path from the root.
190
+ static func _header_of(at: NodePath) -> String:
191
+ var named: String = str(at.get_name(at.get_name_count() - 1))
192
+ var parent: String = "." if at.get_name_count() == 1 else str(at).get_base_dir()
193
+ return '[node name="%s" parent="%s"' % [named.c_escape(), parent.c_escape()]
194
+
195
+
196
+ ## The unique_id [param text] gives the node whose entry opens with [param header], or "".
197
+ static func _filed_id(text: String, header: String) -> String:
198
+ var id: RegEx = RegEx.create_from_string(" unique_id=(\\d+)")
199
+ for line: String in text.split("\n"):
200
+ if line.begins_with(header + " ") or line.begins_with(header + "]"):
201
+ var found: RegExMatch = id.search(line)
202
+ return "" if found == null else found.get_string(1)
203
+ return ""
204
+
205
+
206
+ ## The unique_id to write back on each placeholder put back as its instance, by its entry's header,
207
+ ## for those still in the scene.
208
+ func _ids_to_keep() -> Dictionary:
209
+ var keep: Dictionary = {}
210
+ for key: Variant in _held_ids:
211
+ var node: Node = instance_from_id(GdhRead.as_int(key)) as Node
212
+ if node != null and root.is_ancestor_of(node):
213
+ keep[_header_of(root.get_path_to(node))] = _held_ids[key]
214
+ return keep
215
+
216
+
217
+ ## Saves [param packed] at [param file_path] as [method save_packed] does, with the unique_ids in
218
+ ## [param keep].
219
+ static func _saved_keeping_ids(packed: PackedScene, file_path: String, keep: Dictionary) -> Error:
220
+ var saving: Error = GdhSceneUids.save_keeping_uid(packed, file_path)
221
+ return saving if saving != OK else _keep_ids(file_path, keep)
222
+
223
+
224
+ ## Writes back on the text scene at [param file_path] the unique_id of each entry in [param keep],
225
+ ## by its header.
226
+ static func _keep_ids(file_path: String, keep: Dictionary) -> Error:
227
+ if keep.is_empty():
228
+ return OK
229
+ var lines: PackedStringArray = FileAccess.get_file_as_string(file_path).split("\n")
230
+ var id: RegEx = RegEx.create_from_string(" unique_id=\\d+")
231
+ var changed: bool = false
232
+ for index: int in lines.size():
233
+ for header: Variant in keep:
234
+ var opening: String = str(header)
235
+ if lines[index].begins_with(opening + " ") or lines[index].begins_with(opening + "]"):
236
+ lines[index] = id.sub(lines[index], " unique_id=" + str(keep[header]))
237
+ changed = true
238
+ if not changed:
239
+ return OK
240
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.WRITE)
241
+ if file == null:
242
+ return FileAccess.get_open_error()
243
+ if not file.store_string("\n".join(lines)):
244
+ return ERR_FILE_CANT_WRITE
245
+ file.close()
246
+ return OK
247
+
248
+
249
+ ## Every InstancePlaceholder below [param top] that this scene places, into [param into].
250
+ func _placeholders_under(top: Node, into: Array[InstancePlaceholder]) -> void:
251
+ for child: Node in top.get_children():
252
+ if child is InstancePlaceholder and child.owner == root:
253
+ into.append(child as InstancePlaceholder)
254
+ else:
255
+ _placeholders_under(child, into)
256
+
257
+
258
+ ## Points every value of a node of this scene that holds [param was], alone or in an array, at
259
+ ## [param now].
260
+ func _repointed(top: Node, was: Node, now: Node) -> void:
261
+ if top.owner == root or top == root:
262
+ for declared: Dictionary in top.get_property_list():
263
+ if not GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
264
+ continue
265
+ var named: String = str(declared["name"])
266
+ var value: Variant = top.get(named)
267
+ if value is Object and value == was:
268
+ top.set(named, now)
269
+ elif value is Array:
270
+ var listed: Array = value
271
+ if listed.has(was):
272
+ var swapped: Array = listed.duplicate()
273
+ for index: int in swapped.size():
274
+ if swapped[index] == was:
275
+ swapped[index] = now
276
+ top.set(named, swapped)
277
+ for child: Node in top.get_children():
278
+ _repointed(child, was, now)
279
+
280
+
148
281
  ## Why a write to the scene at [param scene_path], whose text is [param text], is refused, or "":
149
282
  ## an [ext_resource] naming no file the project has, by its uid or by its path. The engine loads the
150
283
  ## scene without it, so a save drops the entry and every value set from it, and the answer could
@@ -1013,6 +1146,7 @@ func _write(check: Callable) -> Dictionary:
1013
1146
  if not missing.is_empty():
1014
1147
  return refuse("Nothing was written: " + missing)
1015
1148
  var also: Array[Dictionary] = _also_saved(packed.get_state())
1149
+ var keep: Dictionary = _ids_to_keep() if headless and path.ends_with(".tscn") else {}
1016
1150
  close()
1017
1151
  var previous: Variant = null
1018
1152
  if FileAccess.file_exists(path):
@@ -1024,12 +1158,18 @@ func _write(check: Callable) -> Dictionary:
1024
1158
  values.append_array(_stored_at(state, index).values())
1025
1159
  var carried: Dictionary = GdhHeadlessCheck.embedded_in(values, {})
1026
1160
  var written: Dictionary = save_packed(packed, path)
1161
+ if written.is_empty():
1162
+ var ids_kept: Error = _keep_ids(path, keep)
1163
+ if ids_kept != OK:
1164
+ written = {
1165
+ "ok": false, "error": "Failed to save %s: %s" % [path, error_string(ids_kept)],
1166
+ }
1027
1167
  if not written.is_empty() or not headless or not path.ends_with(".tscn"):
1028
1168
  if written.is_empty():
1029
1169
  also_saved.append_array(also)
1030
1170
  return written
1031
1171
  var just_saved: GdhHeadlessCheck.GdhSaved = GdhHeadlessCheck.GdhSaved.new(
1032
- path, _text, func() -> Error: return GdhSceneUids.save_keeping_uid(packed, path)
1172
+ path, _text, func() -> Error: return _saved_keeping_ids(packed, path, keep)
1033
1173
  )
1034
1174
  var unkept: String = GdhHeadlessCheck.settle_saved(
1035
1175
  just_saved,
@@ -263,9 +263,15 @@ func add_node(args: Dictionary) -> Dictionary:
263
263
  var parent_node_path: String = str(args.get("parentNodePath", "."))
264
264
  var properties: Dictionary = _parse_properties_arg(args.get("properties", {}))
265
265
  var adding: Array = GdhGroups.asked(args, "addGroups")
266
+ var placeholder: bool = GdhRead.as_bool(args.get("placeholder", false))
266
267
 
267
268
  if node_name.is_empty():
268
269
  return { "ok": false, "error": "Missing nodeName" }
270
+ if placeholder and instance_path.is_empty():
271
+ return {
272
+ "ok": false,
273
+ "error": "placeholder marks an instance of a scene, so add takes it with instancePath.",
274
+ }
269
275
  var class_script: Script = null
270
276
  if not node_type.is_empty() and not ClassDB.class_exists(node_type):
271
277
  var declared: Dictionary = GdhSceneFile.script_class(node_type, "Node")
@@ -312,6 +318,8 @@ func add_node(args: Dictionary) -> Dictionary:
312
318
  parent.add_child(new_node)
313
319
  GdhPlacing.place(new_node, place)
314
320
  GdhPlacing.own(new_node, root)
321
+ if placeholder:
322
+ new_node.set_scene_instance_load_placeholder(true)
315
323
  if class_script != null:
316
324
  scene.asked(new_node, ["script"])
317
325
  new_node.set_script(class_script)
@@ -357,6 +365,8 @@ func add_node(args: Dictionary) -> Dictionary:
357
365
  )
358
366
  if unkept.is_empty() and saved.get_node_or_null(new_path) != null:
359
367
  unkept = GdhGroups.unkept(saved.get_node(new_path), adding, [])
368
+ if unkept.is_empty() and placeholder:
369
+ unkept = _placeholder_unkept(saved, new_path, true)
360
370
  return unkept
361
371
  )
362
372
  if not written.is_empty():
@@ -366,9 +376,25 @@ func add_node(args: Dictionary) -> Dictionary:
366
376
  _note_loads_as(answer, scene, new_path)
367
377
  if packed != null:
368
378
  answer["instanceOf"] = instance_path
379
+ answer["placeholder"] = placeholder
369
380
  return answer
370
381
 
371
382
 
383
+ ## Why the node at [param at] in [param saved], the scene as written, would not load with
384
+ ## placeholder [param wanted], or "" when it would.
385
+ static func _placeholder_unkept(saved: Node, at: String, wanted: bool) -> String:
386
+ var node: Node = saved.get_node_or_null(at)
387
+ if node == null or _placeholder_of(node) == wanted:
388
+ return ""
389
+ return "%s would load with placeholder %s." % [at, not wanted]
390
+
391
+
392
+ ## Whether [param node] is an instance saved as a placeholder. An engine with no editor opens one as
393
+ ## an InstancePlaceholder even in an edit state, and an editor opens the instance itself, marked.
394
+ static func _placeholder_of(node: Node) -> bool:
395
+ return node is InstancePlaceholder or node.get_scene_instance_load_placeholder()
396
+
397
+
372
398
  func delete_node(args: Dictionary) -> Dictionary:
373
399
  var project_path: String = str(args.get("projectPath", ""))
374
400
  var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
@@ -1119,10 +1145,18 @@ func set_node_properties(args: Dictionary) -> Dictionary:
1119
1145
  var editable: Variant = args.get("editableChildren")
1120
1146
  var adding: Array = GdhGroups.asked(args, "addGroups")
1121
1147
  var removing: Array = GdhGroups.asked(args, "removeGroups")
1122
- if properties.is_empty() and editable == null and adding.is_empty() and removing.is_empty():
1148
+ var placeholder: Variant = args.get("placeholder")
1149
+ if (
1150
+ properties.is_empty()
1151
+ and editable == null
1152
+ and placeholder == null
1153
+ and adding.is_empty()
1154
+ and removing.is_empty()
1155
+ ):
1123
1156
  return {
1124
1157
  "ok": false,
1125
- "error": "set takes properties, editableChildren, addGroups or removeGroups.",
1158
+ "error":
1159
+ "set takes properties, editableChildren, placeholder, addGroups or removeGroups.",
1126
1160
  }
1127
1161
 
1128
1162
  var opened: Array = _open(scene_path, true)
@@ -1147,6 +1181,37 @@ func set_node_properties(args: Dictionary) -> Dictionary:
1147
1181
  if not foreign.is_empty():
1148
1182
  return scene.refuse(foreign)
1149
1183
 
1184
+ # A placeholder loads without its children, so the editor never keeps both settings on.
1185
+ var wants_placeholder: bool = placeholder != null and GdhRead.as_bool(placeholder)
1186
+ var wants_editable: bool = editable != null and GdhRead.as_bool(editable)
1187
+ if wants_placeholder and wants_editable:
1188
+ return scene.refuse(
1189
+ (
1190
+ "placeholder and editableChildren cannot both be true, "
1191
+ + "since a placeholder loads without its children."
1192
+ )
1193
+ )
1194
+ if wants_placeholder and editable == null and scene.root.is_editable_instance(node):
1195
+ return scene.refuse(
1196
+ (
1197
+ (
1198
+ "%s has editable children, which a placeholder cannot keep. Pass "
1199
+ + "editableChildren false with placeholder true to drop their saved values."
1200
+ )
1201
+ % scene.path_of(node)
1202
+ )
1203
+ )
1204
+ if wants_editable and placeholder == null and node.get_scene_instance_load_placeholder():
1205
+ return scene.refuse(
1206
+ (
1207
+ (
1208
+ "%s is a placeholder, which loads without its children. "
1209
+ + "Pass placeholder false with editableChildren true."
1210
+ )
1211
+ % scene.path_of(node)
1212
+ )
1213
+ )
1214
+
1150
1215
  var dropped: Dictionary = {}
1151
1216
  if editable != null:
1152
1217
  var not_an_instance: String = GdhKin.set_editable(scene, node, GdhRead.as_bool(editable))
@@ -1154,6 +1219,16 @@ func set_node_properties(args: Dictionary) -> Dictionary:
1154
1219
  return scene.refuse(not_an_instance)
1155
1220
  if not GdhRead.as_bool(editable):
1156
1221
  dropped = scene.held_under(scene.path_of(node))
1222
+ if placeholder != null:
1223
+ # The editor offers Load as Placeholder only on an instance this scene places itself.
1224
+ if node == scene.root or node.owner != scene.root or node.scene_file_path.is_empty():
1225
+ return scene.refuse(
1226
+ (
1227
+ "placeholder is a setting of an instance this scene places, and %s is not one."
1228
+ % scene.path_of(node)
1229
+ )
1230
+ )
1231
+ node.set_scene_instance_load_placeholder(GdhRead.as_bool(placeholder))
1157
1232
 
1158
1233
  var old_path: String = scene.path_of(node)
1159
1234
  scene.asked(node, properties.keys())
@@ -1219,6 +1294,8 @@ func set_node_properties(args: Dictionary) -> Dictionary:
1219
1294
  "%s would load with editableChildren %s."
1220
1295
  % [new_path, not GdhRead.as_bool(editable)]
1221
1296
  )
1297
+ if unkept.is_empty() and placeholder != null:
1298
+ unkept = _placeholder_unkept(saved, new_path, GdhRead.as_bool(placeholder))
1222
1299
  return unkept
1223
1300
  )
1224
1301
  if not written.is_empty():
@@ -1227,6 +1304,8 @@ func set_node_properties(args: Dictionary) -> Dictionary:
1227
1304
 
1228
1305
  if editable != null:
1229
1306
  answer["editableChildren"] = GdhRead.as_bool(editable)
1307
+ if placeholder != null:
1308
+ answer["placeholder"] = GdhRead.as_bool(placeholder)
1230
1309
  if not dropped.is_empty():
1231
1310
  answer["droppedValues"] = dropped["values"]
1232
1311
  answer["droppedNodes"] = dropped["nodes"]
@@ -1404,6 +1483,7 @@ func get_node_properties(args: Dictionary) -> Dictionary:
1404
1483
  if node != scene.root and node.owner != null and not node.scene_file_path.is_empty():
1405
1484
  answer["instanceOf"] = node.scene_file_path
1406
1485
  answer["editableChildren"] = scene.root.is_editable_instance(node)
1486
+ answer["placeholder"] = node.get_scene_instance_load_placeholder()
1407
1487
  scene.close()
1408
1488
  # Only an editor holds anything unsaved. A headless engine answering false would say it looked.
1409
1489
  if Engine.is_editor_hint():
@@ -186,6 +186,7 @@ static func _lay(
186
186
  elif state.is_node_instance_placeholder(index):
187
187
  nodes[path] = _made("InstancePlaceholder")
188
188
  nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
189
+ nodes[path]["placeholder"] = true
189
190
  elif str(state.get_node_type(index)).is_empty():
190
191
  # An entry for a node of an instance that no longer has it, which the engine skips
191
192
  # too.
@@ -367,6 +368,8 @@ static func _entry(
367
368
  data["instanceOf"] = node["instanceOf"]
368
369
  if node.has("editableChildren"):
369
370
  data["editableChildren"] = node["editableChildren"]
371
+ if node.has("placeholder"):
372
+ data["placeholder"] = true
370
373
  # An inherited scene's file holds only what it changes, so the read says what it inherits and
371
374
  # which nodes come from there, as the editor greys them.
372
375
  if node.has("inherits"):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.68",
3
+ "version": "1.3.70",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",