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