From 21a85e2833e461d329d5aeb5d71fd5f6020d9fe5 Mon Sep 17 00:00:00 2001 From: Jan Haller Date: Fri, 24 Oct 2025 18:11:46 +0200 Subject: [PATCH 1/3] Fix ConvertError Display formatting for custom errors Previously, custom errors were formatted with Debug impl, which wrapped the error message in Some("..."). Now properly unwraps the Option and uses Display impl. --- godot-core/src/meta/error/convert_error.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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"), + }, } } } From bbc95a6c8764143460dee3e07d3c4b529650e170 Mon Sep 17 00:00:00 2001 From: Jan Haller Date: Sat, 25 Oct 2025 09:30:03 +0200 Subject: [PATCH 2/3] Add API to fetch autoloads by name --- godot-core/src/tools/autoload.rs | 99 +++++++++++++++++++ godot-core/src/tools/mod.rs | 2 + itest/godot/SpecialTests.gd | 14 +++ itest/godot/TestRunner.tscn | 2 +- itest/godot/gdscript_tests/AutoloadScene.tscn | 3 + itest/godot/project.godot | 4 + itest/rust/src/engine_tests/autoload_test.rs | 67 +++++++++++++ itest/rust/src/engine_tests/mod.rs | 1 + 8 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 godot-core/src/tools/autoload.rs create mode 100644 itest/godot/gdscript_tests/AutoloadScene.tscn create mode 100644 itest/rust/src/engine_tests/autoload_test.rs diff --git a/godot-core/src/tools/autoload.rs b/godot-core/src/tools/autoload.rs new file mode 100644 index 000000000..66eb47ed9 --- /dev/null +++ b/godot-core/src/tools/autoload.rs @@ -0,0 +1,99 @@ +/* + * 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 crate::builtin::NodePath; +use crate::classes::{Engine, Node, SceneTree}; +use crate::meta::error::ConvertError; +use crate::obj::{Gd, Inherits, Singleton}; + +/// Retrieves an autoload by its 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. +/// +/// [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. +/// +/// 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, +{ + 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"))?; + + root.try_get_node_as::(&autoload_path).ok_or_else(|| { + let class = T::class_id(); + ConvertError::new(format!( + "autoload `{autoload_name}` not found or has wrong type (expected {class})", + )) + }) +} diff --git a/godot-core/src/tools/mod.rs b/godot-core/src/tools/mod.rs index 0b27ec75a..7817ed400 100644 --- a/godot-core/src/tools/mod.rs +++ b/godot-core/src/tools/mod.rs @@ -10,10 +10,12 @@ //! 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::*; 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..3d226ec77 --- /dev/null +++ b/itest/rust/src/engine_tests/autoload_test.rs @@ -0,0 +1,67 @@ +/* + * 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; + +#[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"); +} 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; From a2d23de1c3c6360e228c6be25f215f7a0538c83e Mon Sep 17 00:00:00 2001 From: Jan Haller Date: Sat, 25 Oct 2025 11:06:06 +0200 Subject: [PATCH 3/3] Cache autoloads once resolved --- godot-core/src/init/mod.rs | 1 + godot-core/src/tools/autoload.rs | 86 +++++++++++++++++++- godot-core/src/tools/mod.rs | 6 ++ itest/rust/src/engine_tests/autoload_test.rs | 27 +++++- 4 files changed, 115 insertions(+), 5 deletions(-) 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/tools/autoload.rs b/godot-core/src/tools/autoload.rs index 66eb47ed9..c22e3d8ce 100644 --- a/godot-core/src/tools/autoload.rs +++ b/godot-core/src/tools/autoload.rs @@ -5,16 +5,24 @@ * 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 its name. +/// 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 @@ -48,6 +56,8 @@ where /// 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: @@ -76,6 +86,16 @@ pub fn try_get_autoload_by_name(autoload_name: &str) -> Result, Convert 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"))?; @@ -90,10 +110,68 @@ where .get_root() .ok_or_else(|| ConvertError::new("scene tree root not available"))?; - root.try_get_node_as::(&autoload_path).ok_or_else(|| { - let class = T::class_id(); + 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}` not found or has wrong type (expected {class})", + "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 7817ed400..04aafa1cb 100644 --- a/godot-core/src/tools/mod.rs +++ b/godot-core/src/tools/mod.rs @@ -19,3 +19,9 @@ pub use autoload::*; pub use gfile::*; pub use save_load::*; pub use translate::*; + +// ---------------------------------------------------------------------------------------------------------------------------------------------- + +pub(crate) fn cleanup() { + clear_autoload_cache(); +} diff --git a/itest/rust/src/engine_tests/autoload_test.rs b/itest/rust/src/engine_tests/autoload_test.rs index 3d226ec77..cc3a7c7c2 100644 --- a/itest/rust/src/engine_tests/autoload_test.rs +++ b/itest/rust/src/engine_tests/autoload_test.rs @@ -9,7 +9,7 @@ use godot::classes::Node; use godot::prelude::*; use godot::tools::{get_autoload_by_name, try_get_autoload_by_name}; -use crate::framework::itest; +use crate::framework::{itest, quick_thread}; #[derive(GodotClass)] #[class(init, base=Node)] @@ -65,3 +65,28 @@ 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" + ); +}