diff --git a/godot-core/src/init/mod.rs b/godot-core/src/init/mod.rs index 842d6870b..a1eee5ada 100644 --- a/godot-core/src/init/mod.rs +++ b/godot-core/src/init/mod.rs @@ -232,6 +232,7 @@ fn gdext_on_level_deinit(level: InitLevel) { // No business logic by itself, but ensures consistency if re-initialization (hot-reload on Linux) occurs. crate::task::cleanup(); + crate::tools::cleanup(); // Garbage-collect various statics. // SAFETY: this is the last time meta APIs are used. diff --git a/godot-core/src/meta/error/convert_error.rs b/godot-core/src/meta/error/convert_error.rs index 110cb3aa8..12fa2f296 100644 --- a/godot-core/src/meta/error/convert_error.rs +++ b/godot-core/src/meta/error/convert_error.rs @@ -171,7 +171,10 @@ impl fmt::Display for ErrorKind { Self::FromGodot(from_godot) => write!(f, "{from_godot}"), Self::FromVariant(from_variant) => write!(f, "{from_variant}"), Self::FromFfi(from_ffi) => write!(f, "{from_ffi}"), - Self::Custom(cause) => write!(f, "{cause:?}"), + Self::Custom(cause) => match cause { + Some(c) => write!(f, "{c}"), + None => write!(f, "custom error"), + }, } } } diff --git a/godot-core/src/tools/autoload.rs b/godot-core/src/tools/autoload.rs new file mode 100644 index 000000000..c22e3d8ce --- /dev/null +++ b/godot-core/src/tools/autoload.rs @@ -0,0 +1,177 @@ +/* + * Copyright (c) godot-rust; Bromeon and contributors. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use std::cell::RefCell; +use std::collections::HashMap; + +use sys::is_main_thread; + +use crate::builtin::NodePath; +use crate::classes::{Engine, Node, SceneTree}; +use crate::meta::error::ConvertError; +use crate::obj::{Gd, Inherits, Singleton}; +use crate::sys; + +/// Retrieves an autoload by name. +/// +/// See [Godot docs] for an explanation of the autoload concept. Godot sometimes uses the term "autoload" interchangeably with "singleton"; +/// we strictly refer to the former to separate from [`Singleton`][crate::obj::Singleton] objects. +/// +/// If the autoload can be resolved, it will be cached and returned very quickly the second time. +/// +/// [Godot docs]: https://docs.godotengine.org/en/stable/tutorials/scripting/singletons_autoload.html +/// +/// # Panics +/// This is a convenience function that calls [`try_get_autoload_by_name()`]. Panics if that fails, e.g. not found or wrong type. +/// +/// # Example +/// ```no_run +/// use godot::prelude::*; +/// use godot::tools::get_autoload_by_name; +/// +/// #[derive(GodotClass)] +/// #[class(init, base=Node)] +/// struct GlobalStats { +/// base: Base, +/// } +/// +/// // Assuming "Statistics" is registered as an autoload in `project.godot`, +/// // this returns the one instance of type Gd. +/// let stats = get_autoload_by_name::("Statistics"); +/// ``` +pub fn get_autoload_by_name(autoload_name: &str) -> Gd +where + T: Inherits, +{ + try_get_autoload_by_name::(autoload_name) + .unwrap_or_else(|err| panic!("Failed to get autoload `{autoload_name}`: {err}")) +} + +/// Retrieves an autoload by name (fallible). +/// +/// Autoloads are accessed via the `/root/{name}` path in the scene tree. The name is the one you used to register the autoload in +/// `project.godot`. By convention, it often corresponds to the class name, but does not have to. +/// +/// If the autoload can be resolved, it will be cached and returned very quickly the second time. +/// +/// See also [`get_autoload_by_name()`] for simpler function expecting the class name and non-fallible invocation. +/// +/// This function returns `Err` if: +/// - No autoload is registered under `name`. +/// - The autoload cannot be cast to type `T`. +/// - There is an error fetching the scene tree. +/// +/// # Example +/// ```no_run +/// use godot::prelude::*; +/// use godot::tools::try_get_autoload_by_name; +/// +/// #[derive(GodotClass)] +/// #[class(init, base=Node)] +/// struct GlobalStats { +/// base: Base, +/// } +/// +/// let result = try_get_autoload_by_name::("Statistics"); +/// match result { +/// Ok(autoload) => { /* Use the Gd. */ } +/// Err(err) => eprintln!("Failed to get autoload: {err}"), +/// } +/// ``` +pub fn try_get_autoload_by_name(autoload_name: &str) -> Result, ConvertError> +where + T: Inherits, +{ + ensure_main_thread()?; + + // Check cache first. + let cached = AUTOLOAD_CACHE.with(|cache| cache.borrow().get(autoload_name).cloned()); + + if let Some(cached_node) = cached { + return cast_autoload(cached_node, autoload_name); + } + + // Cache miss - fetch from scene tree. + let main_loop = Engine::singleton() + .get_main_loop() + .ok_or_else(|| ConvertError::new("main loop not available"))?; + + let scene_tree = main_loop + .try_cast::() + .map_err(|_| ConvertError::new("main loop is not a SceneTree"))?; + + let autoload_path = NodePath::from(&format!("/root/{autoload_name}")); + + let root = scene_tree + .get_root() + .ok_or_else(|| ConvertError::new("scene tree root not available"))?; + + let autoload_node = root + .try_get_node_as::(&autoload_path) + .ok_or_else(|| ConvertError::new(format!("autoload `{autoload_name}` not found")))?; + + // Store in cache as Gd. + AUTOLOAD_CACHE.with(|cache| { + cache + .borrow_mut() + .insert(autoload_name.to_string(), autoload_node.clone()); + }); + + // Cast to requested type. + cast_autoload(autoload_node, autoload_name) +} + +// ---------------------------------------------------------------------------------------------------------------------------------------------- +// Cache implementation + +thread_local! { + /// Cache for autoloads. Maps autoload name to `Gd`. + /// + /// Uses `thread_local!` because `Gd` is not `Send`/`Sync`. Since all Godot objects must be accessed + /// from the main thread, this is safe. We enforce main-thread access via `ensure_main_thread()`. + static AUTOLOAD_CACHE: RefCell>> = RefCell::new(HashMap::new()); +} + +/// Verifies that the current thread is the main thread. +/// +/// Returns an error if called from a thread other than the main thread. This is necessary because `Gd` is not thread-safe. +fn ensure_main_thread() -> Result<(), ConvertError> { + if is_main_thread() { + Ok(()) + } else { + Err(ConvertError::new( + "Autoloads must be fetched from main thread, as Gd is not thread-safe", + )) + } +} + +/// Casts an autoload node to the requested type, with descriptive error message on failure. +fn cast_autoload(node: Gd, autoload_name: &str) -> Result, ConvertError> +where + T: Inherits, +{ + node.try_cast::().map_err(|node| { + let expected = T::class_id(); + let actual = node.get_class(); + + ConvertError::new(format!( + "autoload `{autoload_name}` has wrong type (expected {expected}, got {actual})", + )) + }) +} + +/// Clears the autoload cache (called during shutdown). +/// +/// # Panics +/// Panics if called from a thread other than the main thread. +pub(crate) fn clear_autoload_cache() { + ensure_main_thread().expect("clear_autoload_cache() must be called from the main thread"); + + AUTOLOAD_CACHE.with(|cache| { + cache.borrow_mut().clear(); + }); +} diff --git a/godot-core/src/tools/mod.rs b/godot-core/src/tools/mod.rs index 0b27ec75a..04aafa1cb 100644 --- a/godot-core/src/tools/mod.rs +++ b/godot-core/src/tools/mod.rs @@ -10,10 +10,18 @@ //! Contains functionality that extends existing Godot classes and functions, to make them more versatile //! or better integrated with Rust. +mod autoload; mod gfile; mod save_load; mod translate; +pub use autoload::*; pub use gfile::*; pub use save_load::*; pub use translate::*; + +// ---------------------------------------------------------------------------------------------------------------------------------------------- + +pub(crate) fn cleanup() { + clear_autoload_cache(); +} diff --git a/itest/godot/SpecialTests.gd b/itest/godot/SpecialTests.gd index e787a45b0..0f752b24a 100644 --- a/itest/godot/SpecialTests.gd +++ b/itest/godot/SpecialTests.gd @@ -57,3 +57,17 @@ func test_collision_object_2d_input_event(): window.queue_free() +func test_autoload(): + var fetched = Engine.get_main_loop().get_root().get_node_or_null("/root/MyAutoload") + assert_that(fetched != null, "MyAutoload should be loaded") + + var by_class: AutoloadClass = fetched + assert_eq(by_class.verify_works(), 787, "Autoload typed by class") + + var by_class_symbol: AutoloadClass = MyAutoload + assert_eq(by_class_symbol.verify_works(), 787, "Autoload typed by class") + + # Autoload in GDScript can be referenced by class name or autoload name, however autoload as a type is only available in Godot 4.3+. + # See https://github.com/godot-rust/gdext/pull/1381#issuecomment-3446111511. + # var by_name: MyAutoload = fetched + # assert_eq(by_name.verify_works(), 787, "Autoload typed by name") diff --git a/itest/godot/TestRunner.tscn b/itest/godot/TestRunner.tscn index dbd5cd740..cf2dc9b53 100644 --- a/itest/godot/TestRunner.tscn +++ b/itest/godot/TestRunner.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3 uid="uid://dgcj68l8n6wpb"] -[ext_resource type="Script" path="res://TestRunner.gd" id="1_wdbrf"] +[ext_resource type="Script" uid="uid://dcsm6ho05dipr" path="res://TestRunner.gd" id="1_wdbrf"] [node name="TestRunner" type="Node"] script = ExtResource("1_wdbrf") diff --git a/itest/godot/gdscript_tests/AutoloadScene.tscn b/itest/godot/gdscript_tests/AutoloadScene.tscn new file mode 100644 index 000000000..f1404c188 --- /dev/null +++ b/itest/godot/gdscript_tests/AutoloadScene.tscn @@ -0,0 +1,3 @@ +[gd_scene format=3 uid="uid://csf04mj3dj8bn"] + +[node name="AutoloadNode" type="AutoloadClass"] diff --git a/itest/godot/project.godot b/itest/godot/project.godot index 049e2ba0a..fdccb81bb 100644 --- a/itest/godot/project.godot +++ b/itest/godot/project.godot @@ -15,6 +15,10 @@ run/main_scene="res://TestRunner.tscn" config/features=PackedStringArray("4.2") run/flush_stdout_on_print=true +[autoload] + +MyAutoload="*res://gdscript_tests/AutoloadScene.tscn" + [debug] gdscript/warnings/shadowed_variable=0 diff --git a/itest/rust/src/engine_tests/autoload_test.rs b/itest/rust/src/engine_tests/autoload_test.rs new file mode 100644 index 000000000..cc3a7c7c2 --- /dev/null +++ b/itest/rust/src/engine_tests/autoload_test.rs @@ -0,0 +1,92 @@ +/* + * Copyright (c) godot-rust; Bromeon and contributors. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use godot::classes::Node; +use godot::prelude::*; +use godot::tools::{get_autoload_by_name, try_get_autoload_by_name}; + +use crate::framework::{itest, quick_thread}; + +#[derive(GodotClass)] +#[class(init, base=Node)] +struct AutoloadClass { + base: Base, + #[var] + property: i32, +} + +#[godot_api] +impl AutoloadClass { + #[func] + fn verify_works(&self) -> i32 { + 787 + } +} + +#[itest] +fn autoload_get() { + let mut autoload = get_autoload_by_name::("MyAutoload"); + { + let mut guard = autoload.bind_mut(); + assert_eq!(guard.verify_works(), 787); + assert_eq!(guard.property, 0, "still has default value"); + + guard.property = 42; + } + + // Fetch same autoload anew. + let autoload2 = get_autoload_by_name::("MyAutoload"); + assert_eq!(autoload2.bind().property, 42); + + // Reset for other tests. + autoload.bind_mut().property = 0; +} + +#[itest] +fn autoload_try_get_named() { + let autoload = try_get_autoload_by_name::("MyAutoload").expect("fetch autoload"); + + assert_eq!(autoload.bind().verify_works(), 787); + assert_eq!(autoload.bind().property, 0, "still has default value"); +} + +#[itest] +fn autoload_try_get_named_inexistent() { + let result = try_get_autoload_by_name::("InexistentAutoload"); + result.expect_err("non-existent autoload"); +} + +#[itest] +fn autoload_try_get_named_bad_type() { + let result = try_get_autoload_by_name::("MyAutoload"); + result.expect_err("autoload of incompatible node type"); +} + +#[itest] +fn autoload_from_other_thread() { + use std::sync::{Arc, Mutex}; + + // We can't return the Result from the thread because Gd is not Send, so we extract the error message instead. + let outer_error = Arc::new(Mutex::new(String::new())); + let inner_error = Arc::clone(&outer_error); + + quick_thread(move || { + let result = try_get_autoload_by_name::("MyAutoload"); + match result { + Ok(_) => panic!("autoload access from non-main thread should fail"), + Err(err) => { + *inner_error.lock().unwrap() = err.to_string(); + } + } + }); + + let msg = outer_error.lock().unwrap(); + assert_eq!( + *msg, + "Autoloads must be fetched from main thread, as Gd is not thread-safe" + ); +} diff --git a/itest/rust/src/engine_tests/mod.rs b/itest/rust/src/engine_tests/mod.rs index 7a7c5503d..b5edaefd7 100644 --- a/itest/rust/src/engine_tests/mod.rs +++ b/itest/rust/src/engine_tests/mod.rs @@ -6,6 +6,7 @@ */ mod async_test; +mod autoload_test; mod codegen_enums_test; mod codegen_test; mod engine_enum_test;