Files
TheTree_mock/scripts/ui/ui_audio_owner_shutdown.gd
Takeshi b84bade045 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.
2026-10-11 15:58:00 +09:00

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