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.
172 lines
7.6 KiB
GDScript
172 lines
7.6 KiB
GDScript
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
|