Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
extends RefCounted
|
||||
|
||||
## One close coordinator per SceneTree; owners remain alive while audio drains.
|
||||
## Runtime values, settings and shared AudioServer buses are never rewritten.
|
||||
const OWNER_META := &"_the_tree_ui_close_owners"
|
||||
const MAXIMUM_WAIT_US := 500000
|
||||
|
||||
|
||||
static func register_owner(tree: SceneTree, owner: Node) -> void:
|
||||
var state: Dictionary
|
||||
if tree.root.has_meta(OWNER_META):
|
||||
state = tree.root.get_meta(OWNER_META)
|
||||
else:
|
||||
state = {&"original_auto_accept_quit": tree.auto_accept_quit, &"owners": [], &"closing": false}
|
||||
tree.root.set_meta(OWNER_META, state)
|
||||
var owners: Array = state[&"owners"]
|
||||
for reference: WeakRef in owners:
|
||||
if reference.get_ref() == owner:
|
||||
return
|
||||
owners.append(weakref(owner))
|
||||
tree.auto_accept_quit = false
|
||||
|
||||
|
||||
static func unregister_owner(tree: SceneTree, owner: Node) -> void:
|
||||
if not tree.root.has_meta(OWNER_META):
|
||||
return
|
||||
var state: Dictionary = tree.root.get_meta(OWNER_META)
|
||||
var kept: Array = []
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var node: Variant = reference.get_ref()
|
||||
if is_instance_valid(node) and node != owner:
|
||||
kept.append(reference)
|
||||
state[&"owners"] = kept
|
||||
if kept.is_empty() and not bool(state[&"closing"]):
|
||||
tree.auto_accept_quit = bool(state[&"original_auto_accept_quit"])
|
||||
tree.root.remove_meta(OWNER_META)
|
||||
|
||||
|
||||
static func capture_and_release(audio: Node) -> Array[Dictionary]:
|
||||
var targets := _capture_targets(audio)
|
||||
# This synchronous frame owns all strong player/stream/playback locals.
|
||||
# It returns only WeakRefs before the coordinator's first await.
|
||||
if is_instance_valid(audio):
|
||||
targets.append({&"role": &"owner", &"kind": &"audio_owner", &"weak": weakref(audio), &"instance_id": audio.get_instance_id()})
|
||||
audio.call(&"prepare_owner_shutdown")
|
||||
audio.queue_free()
|
||||
return targets
|
||||
|
||||
|
||||
static func close_owners(tree: SceneTree) -> Dictionary:
|
||||
if not tree.root.has_meta(OWNER_META):
|
||||
return {&"coordinator": false}
|
||||
var state: Dictionary = tree.root.get_meta(OWNER_META)
|
||||
if bool(state[&"closing"]):
|
||||
return {&"coordinator": false}
|
||||
state[&"closing"] = true
|
||||
var clock: Node = tree.root.get_node_or_null("GameClock")
|
||||
var clock_was_processing: bool = clock.is_processing() if is_instance_valid(clock) else false
|
||||
var windows: Array[Dictionary] = []
|
||||
_disable_window_input(tree.root, windows)
|
||||
if is_instance_valid(clock):
|
||||
clock.set_process(false)
|
||||
var targets: Array[Dictionary] = []
|
||||
var prepared: Array[int] = []
|
||||
_prepare_pending_owners(state, targets, prepared, windows)
|
||||
var initial := _states(targets)
|
||||
var started := Time.get_ticks_usec()
|
||||
await tree.process_frame
|
||||
while Time.get_ticks_usec() - started < MAXIMUM_WAIT_US:
|
||||
_prepare_pending_owners(state, targets, prepared, windows)
|
||||
if not _any_alive(targets):
|
||||
break
|
||||
await tree.process_frame
|
||||
var waited_us := Time.get_ticks_usec() - started
|
||||
var deadline_reached := waited_us >= MAXIMUM_WAIT_US
|
||||
# Closing lasts through the engine's final frame. Restoring here permits a
|
||||
# real Clock tick after this report and before SceneTree quit takes effect.
|
||||
# Keep only ephemeral process/GUI flags latched; saved values stay exact.
|
||||
var report := {&"coordinator": true, &"owned_engine_pid": OS.get_process_id(), &"owner_instance_ids": prepared,
|
||||
&"waited_actual_us": waited_us, &"maximum_wait_us": MAXIMUM_WAIT_US, &"deadline_reached": deadline_reached,
|
||||
&"initial_targets": initial, &"final_targets": _states(targets), &"all_owned_targets_released": not _any_alive(targets),
|
||||
&"clock_process_before": clock_was_processing, &"clock_process_held_stopped": is_instance_valid(clock) and not clock.is_processing(),
|
||||
&"window_input_held_disabled": _window_inputs_held_disabled(windows), &"original_window_input_flags": _window_input_records(windows),
|
||||
&"restored_before_quit": false,
|
||||
&"shared_bus_deleted": false, &"settings_mutated": false,
|
||||
&"passed": not deadline_reached and not _any_alive(targets) and _window_inputs_held_disabled(windows) and is_instance_valid(clock) and not clock.is_processing()}
|
||||
state[&"last_report"] = report
|
||||
# The static coordinator owns final quit, independently of a Main lifetime.
|
||||
# A normal close keeps Main alive; a QA removal cannot orphan this await.
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var owner: Variant = reference.get_ref()
|
||||
if is_instance_valid(owner):
|
||||
owner.call(&"_report_audio_close_drained", report)
|
||||
if not bool(report[&"passed"]):
|
||||
push_error("UI audio close did not release its owners and hold input/clock stopped through final quit")
|
||||
tree.quit(0 if bool(report[&"passed"]) else 1)
|
||||
return report
|
||||
|
||||
|
||||
static func _disable_window_input(node: Node, windows: Array[Dictionary]) -> void:
|
||||
if node is Window:
|
||||
var window := node as Window
|
||||
var known := false
|
||||
for row: Dictionary in windows:
|
||||
known = known or (row[&"weak"] as WeakRef).get_ref() == window
|
||||
if not known:
|
||||
windows.append({&"weak": weakref(window), &"instance_id": window.get_instance_id(), &"gui_disable_input": window.gui_disable_input})
|
||||
window.gui_disable_input = true
|
||||
if window != node.get_tree().root:
|
||||
window.hide()
|
||||
for child: Node in node.get_children():
|
||||
_disable_window_input(child, windows)
|
||||
|
||||
|
||||
static func _window_input_records(windows: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for row: Dictionary in windows:
|
||||
var window: Variant = (row[&"weak"] as WeakRef).get_ref()
|
||||
result.append({&"instance_id": row[&"instance_id"], &"original_gui_disable_input": bool(row[&"gui_disable_input"]),
|
||||
&"alive_at_final_drain": is_instance_valid(window), &"gui_disable_input_at_final_drain": window.gui_disable_input if is_instance_valid(window) else null})
|
||||
return result
|
||||
|
||||
|
||||
static func _window_inputs_held_disabled(windows: Array[Dictionary]) -> bool:
|
||||
for row: Dictionary in windows:
|
||||
var window: Variant = (row[&"weak"] as WeakRef).get_ref()
|
||||
if is_instance_valid(window) and not window.gui_disable_input:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _prepare_pending_owners(state: Dictionary, targets: Array[Dictionary], prepared: Array[int], windows: Array[Dictionary]) -> void:
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var owner: Variant = reference.get_ref()
|
||||
if not is_instance_valid(owner) or prepared.has(owner.get_instance_id()):
|
||||
continue
|
||||
prepared.append(owner.get_instance_id())
|
||||
_disable_window_input(owner, windows)
|
||||
targets.append_array(owner.call(&"_prepare_owned_audio_close"))
|
||||
|
||||
|
||||
static func _capture_targets(audio: Node) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
if not is_instance_valid(audio):
|
||||
return rows
|
||||
for role: StringName in audio.get(&"_players"):
|
||||
var player: AudioStreamPlayer = audio.get(&"_players")[role]
|
||||
if player.stream != null:
|
||||
rows.append({&"role": role, &"kind": &"stream", &"weak": weakref(player.stream), &"instance_id": player.stream.get_instance_id()})
|
||||
var packets: Variant = player.stream.get(&"packet_sequence")
|
||||
if packets is Resource:
|
||||
rows.append({&"role": role, &"kind": &"packet_sequence", &"weak": weakref(packets), &"instance_id": packets.get_instance_id()})
|
||||
if player.has_stream_playback():
|
||||
var playback := player.get_stream_playback()
|
||||
rows.append({&"role": role, &"kind": &"playback", &"weak": weakref(playback), &"instance_id": playback.get_instance_id()})
|
||||
return rows
|
||||
|
||||
|
||||
static func _states(targets: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for target: Dictionary in targets:
|
||||
var resource: Variant = (target[&"weak"] as WeakRef).get_ref()
|
||||
rows.append({&"role": target[&"role"], &"kind": target[&"kind"], &"instance_id": target[&"instance_id"], &"alive": resource != null, &"class": resource.get_class() if resource != null else &""})
|
||||
return rows
|
||||
|
||||
|
||||
static func _any_alive(targets: Array[Dictionary]) -> bool:
|
||||
for row: Dictionary in _states(targets):
|
||||
if bool(row[&"alive"]):
|
||||
return true
|
||||
return false
|
||||
Reference in New Issue
Block a user