Skip to content

Conversation

@Yarwin
Copy link
Contributor

@Yarwin Yarwin commented Nov 5, 2025

Allows to register user-defined engine singletons via #[class(singleton)]`.

#[derive(GodotClass)]
// Can not inherit RefCounted - guaranteed by trait.
#[class(init, singleton, base = Object)]
struct MyEngineSingleton {...}

...

// Same access as for built-in Engine Singletons via Class:singleton():
let var = MyEngineSingleton::singleton().bind().my_var;

For now it has same API as engine singletons and follows the same logic – T::singleton() returns some instance.
GDScript will prohibit creating a new instance of the singleton (both in the Godot Editor and on runtime):

 ERROR: res://node.tscn::GDScript_nxiqb:7 - Parse Error: Cannot construct native class "ClassInEditor" because it is an engine singleton.
  ERROR: res://node.tscn::GDScript_nxiqb:8 - Parse Error: Cannot construct native class "MyEngineSingleton" because it is an engine singleton.

One can register their own user singleton without the proc-macro like so:

#[derive(GodotClass)]
#[class(init, base = Object)]
struct MyEngineSingleton {}

// Provides blanket implementation allowing to use MyEngineSingleton::singleton().
// Makes sure that `MyEngineSingleton` is valid singleton (i.e. is non-refcounted GodotClass).
impl UserSingleton for MyEngineSingleton {}

#[gdextension]
unsafe impl ExtensionLibrary for MyExtension {
    fn on_stage_init(stage: InitStage) {
        if matches!(stage, InitStage::MainLoop) {
            let obj = MyEngineSingleton::new_alloc();
            // Name of the registered singleton **must** be the same as a class one (i.e. use `class_id().to_string_name()`)
            // otherwise other Godot components (for example GDScript before 4.4) will have troubles handling it 
            // and editor might crash while using `T::singleton()`.
            Engine::singleton()
                .register_singleton(&MyEngineSingleton::class_id().to_string_name(), &obj);
        }
    }

    fn on_stage_deinit(stage: InitStage) {
        if matches!(stage, InitStage::MainLoop) {
            let obj = MyEngineSingleton::singleton();
            Engine::singleton()
                .unregister_singleton(&MyEngineSingleton::class_id().to_string_name());
            obj.free();
        }
    }
}

Creating new instance

init is required to properly register given singleton – mostly for Editor purposes (I decided to not mess with it so far). GDScript won't allow to create new instances of class registered as engine singleton, and in the future it might be good idea to block doing so from godot-rust side as well.

Caching

Unimplemented for now. Godot keeps engine singletons on its side in big hashmap; While implementing caching we must make sure it is not redundant 😅.

@Yarwin Yarwin added feature Adds functionality to the library c: register Register classes, functions and other symbols to GDScript labels Nov 5, 2025
@GodotRust
Copy link

API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-1399

T: UserSingleton,
{
fn singleton() -> Gd<T> {
unsafe {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing a SAFETY comment.

Copy link
Contributor Author

@Yarwin Yarwin Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very silly overlook on my part – I added properly panicking version for safeguards_strict level and wrote proper SAFETY remark for disengaged ones.

@Yarwin Yarwin force-pushed the add-user-singletons branch 4 times, most recently from eafbcd6 to 20baefb Compare November 6, 2025 08:09
@Yarwin
Copy link
Contributor Author

Yarwin commented Nov 6, 2025

Note – our test on_init_core fails for Godot before 4.4 (we never noticed because of Godot's philosophy of running despite the errors) – I'll take care of it in separate PR.

Backtrace
Initialize godot-rust (API v4.3.stable.official, runtime v4.3.stable.custom_build, safeguards strict)
ERROR: Failed to retrieve non-existent singleton 'Engine'.
   at: get_singleton_object (core/config/engine.cpp:294)
ERROR: [panic godot-core/src/obj/gd.rs:614]
  Gd::from_obj_sys() called with null pointer
  Context: failed to initialize GDExtension level `Core`
   at: godot_core::private::set_gdext_hook::{{closure}} (godot-core/src/private.rs:324)
[panic backtrace]
   0: godot_core::private::set_gdext_hook::{{closure}}
             at /home/runner/work/gdext/gdext/godot-core/src/private.rs:301:44
   1: <alloc::boxed::Box<F,A> as core::ops::function::Fn<Args>>::call
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/alloc/src/boxed.rs:1999:9
   2: std::panicking::panic_with_hook
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/std/src/panicking.rs:842:13
   3: std::panicking::panic_handler::{{closure}}
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/std/src/panicking.rs:700:13
   4: std::sys::backtrace::__rust_end_short_backtrace
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/std/src/sys/backtrace.rs:174:18
   5: __rustc::rust_begin_unwind
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/std/src/panicking.rs:698:5
   6: core::panicking::panic_fmt
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/core/src/panicking.rs:75:14
   7: godot_core::obj::gd::Gd<T>::from_obj_sys
             at /home/runner/work/gdext/gdext/godot-core/src/obj/gd.rs:614:9
   8: godot_core::classes::class_runtime::singleton_unchecked
             at /home/runner/work/gdext/gdext/godot-core/src/classes/class_runtime.rs:204:5
   9: <godot_core::gen::classes::engine::re_export::Engine as godot_core::obj::traits::Singleton>::singleton
             at /home/runner/work/gdext/gdext/target/debug/build/godot-core-fee422b23948b5eb/out/classes/engine.rs:393:17
  10: itest::object_tests::init_stage_test::on_init_core
             at /home/runner/work/gdext/gdext/itest/rust/src/object_tests/init_stage_test.rs:91:18
  11: itest::object_tests::init_stage_test::on_stage_init
             at /home/runner/work/gdext/gdext/itest/rust/src/object_tests/init_stage_test.rs:66:28
  12: itest::<impl godot_core::init::ExtensionLibrary for itest::framework::runner::IntegrationTests>::on_stage_init
             at /home/runner/work/gdext/gdext/itest/rust/src/lib.rs:28:9
  13: godot_core::init::ffi_initialize_layer::try_load
             at /home/runner/work/gdext/gdext/godot-core/src/init/mod.rs:149:9
  14: godot_core::init::ffi_initialize_layer::{{closure}}
             at /home/runner/work/gdext/gdext/godot-core/src/init/mod.rs:154:9
  15: std::panicking::catch_unwind::do_call
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/std/src/panicking.rs:590:40
  16: __rust_try
  17: std::panicking::catch_unwind
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/std/src/panicking.rs:553:19
  18: std::panic::catch_unwind
             at /rustc/f8297e351a40c1439a467bbbb6879088047f50b3/library/std/src/panic.rs:359:14
  19: godot_core::private::handle_panic
             at /home/runner/work/gdext/gdext/godot-core/src/private.rs:436:18
  20: godot_core::init::ffi_initialize_layer
             at /home/runner/work/gdext/gdext/godot-core/src/init/mod.rs:153:13
  21: _ZN11GDExtension18initialize_libraryENS_19InitializationLevelE
             at /home/runner/work/godot4-nightly/godot4-nightly/core/extension/gdextension.cpp:856:27
  22: _ZN18GDExtensionManager21initialize_extensionsEN11GDExtension19InitializationLevelE
             at /home/runner/work/godot4-nightly/godot4-nightly/core/extension/gdextension_manager.cpp:185:30
  23: _Z24register_core_extensionsv
             at /home/runner/work/godot4-nightly/godot4-nightly/core/register_core_types.cpp:362:44
  24: _ZN4Main5setupEPKciPPcb
             at /home/runner/work/godot4-nightly/godot4-nightly/main/main.cpp:1822:26
  25: main
             at /home/runner/work/godot4-nightly/godot4-nightly/platform/linuxbsd/godot_linuxbsd.cpp:74:25
  26: <unknown>
  27: __libc_start_main
  28: _start

Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot!

Out of curiosity, what does a separate UserSingleton marker trait buy us, that implementing Singleton for each user class individually does not?

Comment on lines +692 to +695
pub trait UserSingleton:
GodotClass + Bounds<Declarer = bounds::DeclUser, Memory = bounds::MemManual>
{
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why limit to MemManual?

If #522 is the reason, should we try to fix that at some point? 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, honestly I haven't been checking this case – RefCounted Engine Singletons just didn't make sense to me 🤔. Engine Singleton should be valid as long as library is loaded (as opposed to RefCounted which are valid as long as something keeps reference to them – and in this case engine should always keep a reference to it) and must be pruned in-between hot reloads.

I can look into supporting this case as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would Object still be automatically-managed (library destroys it on unload)? If yes, that would be OK to start with, and we may indeed not need to support RefCounted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would Object still be automatically-managed (library destroys it on unload)

yep, that's implemented

Comment on lines 254 to 256
// Will imminently add given class to the editor in Godot before 4.4.1.
// It is expected and beneficial behaviour while we load library for the first time
// but (for now) might lead to some issues during hot reload.
// but might lead to some issues during hot reload.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean with 4.4.1 -- what happens after that version?

Further, I know the comment about hot-reload issues was there before, but do you think we can concretize it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4.4.1 has cherry-picked fix which postpones adding EditorPlugins: godotengine/godot#109310 (linked in the issue).

As for concretization – I think I can distill it to one comment over let mut editor_plugins: Vec<ClassId> = Vec::new(); in line 226.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whenever there's a change in behavior that you deem relevant for our godot-rust logic, please always link the relevant upstream PR/issue from code.

"before 4.4.1" without any context will look like magic in a couple months 🙂

Comment on lines 713 to 715
crate::classes::Engine::singleton()
.get_singleton(&<T as GodotClass>::class_id().to_string_name())
.unwrap_or_else(|| panic!("Singleton {} not found. User singleton must be registered under its class name.", T::class_id())).cast()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could make this a bit easier to understand by declaring T::class_id() in a separate variable. Then you can also use inline {class}.

The formatting seems weird, I'd expect cast() to be on another line...

Maybe it's worth a comment why downcasting will not panic in this case.

@Yarwin Yarwin force-pushed the add-user-singletons branch from 20baefb to 55a62ff Compare November 6, 2025 09:50
@Yarwin
Copy link
Contributor Author

Yarwin commented Nov 6, 2025

Out of curiosity, what does a separate UserSingleton marker trait buy us, that implementing Singleton for each user class individually does not?

Mostly blanket implementation – having it in one concrete place makes it much easier to debug + I didn't want to expose crate::classes::singleton_unchecked.

pub use crate::obj::NewAlloc as _;
pub use crate::obj::NewGd as _;
pub use crate::obj::Singleton as _; // singleton()
pub use crate::obj::UserSingleton as _; // User singleton.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments list the methods that the trait inserts into the prelude. Since there are none for UserSingleton, there is no need for a comment.

/// let val = MySingleton::singleton().bind().my_field;
/// ```
///
/// Engine Singletons always run in the editor (similarly to `#[class(tool)]`) and will never be represented by a placeholder instances.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that also mean they cannot be added/removed during hot reloads? If yes, maybe comment

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will never be represented by a placeholder instances

How is this true? From what I see, singletons are always instantiated like all other Godot classes. So when they are not class(tool), they will be instantiated as a placeholder instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this true?

It is counterintuitive, but instances created via GDExtension won't be placeholders – in the same fashion if we create some GDExtension class inheriting Node via godot-rust and add it to the tree its lifecycle methods (enter tree/ready) will be fired (i.e. there isn't any singleton/library magic involved).

One can easily check it in practice:

// no `#[class(tool)]`
#[derive(GodotClass)]
#[class(init, singleton, base = Object)]
struct MyEngineSingleton {
}

#[godot_api]
impl MyEngineSingleton {
    #[func]
    fn test_func2(&self) -> u32 {
        44
    }
}
@tool
extends Node2D

func _ready() -> void:
	print(MyEngineSingleton.test_func2())
	print(MyEngineSingleton.test_var)

prints:

44
59

As for the second case:

#[derive(GodotClass)]
#[class(init, base = Node)]
struct NonToolNode {}

#[godot_api]
impl INode for NonToolNode {
    fn ready(&mut self) {
        godot_print!("Hello from editor :)");
    }
}

#[derive(GodotClass)]
#[class(init, tool, base = Node)]
struct MyClass {
    base: Base<Node>,
}

#[godot_api]
impl INode for MyClass {
    fn ready(&mut self) {
        let mut non_tool_node = NonToolNode::new_alloc();
        self.base_mut().add_child(&non_tool_node);
        non_tool_node.set_owner(self.base().get_owner().as_ref());
    }
}
image

Copy link
Contributor Author

@Yarwin Yarwin Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

honestly I do wonder if we shouldn't require (or imply) #[class(tool)] to prevent breakage in case if this quirk would be addressed 🤔 Up to you.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it looks like all the placeholder logic is handled in ClassDB::_instantiate_internal and since our Gd::new_alloc skips the ClassDB and directly calls the extension's create_instance function, we will never create placeholder instances...

I think it would be a valid use case to create singletons for runtime classes that are only instantiated in the game and not the editor.

One way to work around this would be to go through ClassDB::instantiate() for singletons. But I think it's still very unexpected that Gd::new_alloc bypasses the runtime class system. Maybe this should be discussed in a separate issue. 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you create an issue for this? Maybe describe if it also has an impact outside singletons 🤔

Comment on lines 496 to 497
/// // Can be accessed as any other engine singleton.
/// let val = MySingleton::singleton().bind().my_field;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// // Can be accessed as any other engine singleton.
/// let val = MySingleton::singleton().bind().my_field;
/// // Can be accessed like any other engine singleton.
/// let val = MySingleton::singleton().bind().my_field;

Maybe add "for now limited to the main thread" or so 🤔

Comment on lines 56 to 59
#[derive(GodotClass)]
#[class(init, singleton, base = Object)]
struct SomeUserSingleton {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do wonder -- should we change the default base to Object if singleton is specified?

Is there a good use case to use other base classes like Node?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do wonder -- should we change the default base to Object if singleton is specified?

Not really, we don't do so anywhere else (everywhere else no base implies RefCounted) – we can restrict singletons to Objects via UserSingleton trait.

Is there a good use case to use other base classes like Node?

Theoretically there is but already covered by EnginePlugins and autoloads 🤔. In the past I was registering autoloads as engine singletons using enter/exit tree but this workflow is ultra janky and prone to failure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does mean there is useless boilerplate base=Object required for every singleton though, even though in this case memory management is abstracted from the user.

Even more so if tool is also required...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some consideration – I think that #[class(singleton)] implying #[class(singleton, tool, base = Object)] wouldn't be out of the place if properly documented 🤔.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should definitely document it 👍 maybe also in the place where base=... which currently says RefCounted is the default.

Comment on lines 714 to 717
let singleton = crate::classes::Engine::singleton()
.get_singleton(&class.to_string_name())
.unwrap_or_else(|| panic!("Singleton {class} not found. User singleton must be registered under its class name."));
singleton.try_cast().unwrap_or_else(|obj| panic!("Downcasting of User Engine Singleton {class} failed. Expected: {class}, found: {}", obj.dynamic_class_string()))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting is not great, is it one of the cases where rustfmt gives up due to panic! being a macro?

Does it get better if you don't use an intermediate variable, but directly chain try_cast() after unwrap_or_else()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, it is due to #[cfg(safeguards_strict)] {}.

@Yarwin Yarwin force-pushed the add-user-singletons branch from 55a62ff to 29b323f Compare November 8, 2025 06:53
Comment on lines 701 to 734
fn singleton() -> Gd<T> {
if !cfg!(safeguards_strict) {
// Note: with any safeguards enabled `singleton_unchecked` will panic if Singleton can't be retrieved.

// SAFETY: The caller must ensure that `class_name` corresponds to the actual class name of type `T`.
// This is always true for proc-macro derived user singletons.
unsafe {
crate::classes::singleton_unchecked(&<T as GodotClass>::class_id().to_string_name())
}
} else {
let class = <T as GodotClass>::class_id();
crate::classes::Engine::singleton()
.get_singleton(&class.to_string_name())
.unwrap_or_else(||
panic!(
"Singleton {class} not found. User singleton must be registered under its class name."
))
.try_cast()
.unwrap_or_else(|obj|
panic!(
"Downcasting of User Engine Singleton {class} failed. Expected: {class}, found: {}",
obj.dynamic_class_string()
)
)
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting is still incorrect (e.g. last panic! not indented). Also, the if !cond { ... } else { ... } is an antipattern, use the positive condition first. In this case it might make more sense to do #[cfg(safeguards_strict)] across the entire singleton function, since the implementations are completely different.

Maybe general question -- what exactly are we protecting against here? The validation failing would be a bug in godot-rust right?

And is there any advantage of Engine::get_singleton() vs. using this function directly, but with Gd::<Object>::from_obj_sys(...).cast::<T>()?

pub(crate) unsafe fn singleton_unchecked<T>(class_name: &StringName) -> Gd<T>
where
T: GodotClass,
{
let object_ptr = unsafe { sys::interface_fn!(global_get_singleton)(class_name.string_sys()) };
Gd::<T>::from_obj_sys(object_ptr)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only difference were more informative panics – I just checked and Engine provide relevant error by itself:

ERROR: Failed to retrieve non-existent singleton 'SomeUserSingleton'.
   at: get_singleton_object (core/config/engine.cpp:294)

Therefore I simplified impl to only one case.

Maybe general question -- what exactly are we protecting against here? The validation failing would be a bug in godot-rust right?

Our singletons should always be valid, but it might fail in case of manual registration.

impl ::godot::obj::GodotClass for #class_name {
type Base = #base_class;

#init_level_impl
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not an impl.
Also, it is only relevant for singletons.

Suggested change
#init_level_impl
#singleton_init_level_const


fn make_with_base_impl(base_field: &Option<Field>, class_name: &Ident) -> TokenStream {
if let Some(Field { name, ty, .. }) = base_field {
// Apply the span of the field's type so that errors show up on the field's type.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean "on the field"?

Suggested change
// Apply the span of the field's type so that errors show up on the field's type.
// Apply the span of the field's type so that errors show up on the field.


/// Generates registration for user singleton and proper INIT_LEVEL declaration.
///
/// Before Godot4.4 built-in Engine singleton – required for registration – wasn't available before `InitLevel::Scene`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Before Godot4.4 built-in Engine singleton required for registration wasn't available before `InitLevel::Scene`.
/// Before Godot4.4, built-in engine singleton -- required for registration -- wasn't available before `InitLevel::Scene`.

/// ```
///
/// Engine singletons always run in the editor (similarly to `#[class(tool)]`) and will never be represented by a placeholder instances.
/// During hot reload user-defined engine singleton will be freed while unloading the library and then freshly instantiated after class registration.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// During hot reload user-defined engine singleton will be freed while unloading the library and then freshly instantiated after class registration.
/// During hot reload, user-defined engine singletons will be freed while unloading the library, and then freshly instantiated after class registration.

Allow to register user-defined engine singletons via #[class(singleton)]`.
@Yarwin Yarwin force-pushed the add-user-singletons branch from 16f292a to 3391ae0 Compare November 9, 2025 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c: register Register classes, functions and other symbols to GDScript feature Adds functionality to the library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants