diff --git a/.github/workflows/full-ci.yml b/.github/workflows/full-ci.yml index ae8f4ebe2..f072783dc 100644 --- a/.github/workflows/full-ci.yml +++ b/.github/workflows/full-ci.yml @@ -283,6 +283,7 @@ jobs: os: macos-15 artifact-name: macos-arm-nightly godot-binary: godot.macos.editor.dev.arm64 + rust-extra-args: --features itest/upcoming-editor-placeholders hot-reload: stable signal-test # api-custom on macOS arm64 not working, due to clang linker issues. @@ -305,6 +306,7 @@ jobs: os: windows-2025 artifact-name: windows-nightly godot-binary: godot.windows.editor.dev.x86_64.exe + rust-extra-args: --features itest/upcoming-editor-placeholders hot-reload: stable signal-test - name: windows-double @@ -347,7 +349,7 @@ jobs: artifact-name: linux-nightly godot-binary: godot.linuxbsd.editor.dev.x86_64 # Important to keep both experimental-threads and codegen-full. Some itests (native_st_audio) require both. - rust-extra-args: --features itest/experimental-threads,itest/codegen-full-experimental,godot/api-custom,godot/serde,itest/register-docs + rust-extra-args: --features itest/experimental-threads,itest/codegen-full-experimental,itest/upcoming-editor-placeholders,godot/api-custom,godot/serde,itest/register-docs # Compiles godot-rust with `api-custom-json` feature against the JSON file generated via `--dump-extension-api`. # Uses latest 4.x headers, while `extension_api.json` comes from the latest Godot binary. @@ -397,6 +399,7 @@ jobs: artifact-name: linux-4.3 godot-binary: godot.linuxbsd.editor.dev.x86_64 godot-prebuilt-patch: "4.3" + rust-extra-args: --features itest/upcoming-editor-placeholders # enabled at 4.3 boundary hot-reload: api-4-3 # Can be disabled in the future (already covered by memcheck). For now kept on to differentiate quicker. @@ -405,6 +408,7 @@ jobs: artifact-name: linux-4.2 godot-binary: godot.linuxbsd.editor.dev.x86_64 godot-prebuilt-patch: "4.2" + rust-extra-args: --features itest/upcoming-editor-placeholders # enabled before 4.3 boundary hot-reload: api-4-2 # Memory checks: special Godot binaries compiled with AddressSanitizer/LeakSanitizer to detect UB/leaks. Always Linux. diff --git a/.github/workflows/minimal-ci.yml b/.github/workflows/minimal-ci.yml index 62ab84ab4..2b60cb859 100644 --- a/.github/workflows/minimal-ci.yml +++ b/.github/workflows/minimal-ci.yml @@ -186,6 +186,7 @@ jobs: os: macos-15 artifact-name: macos-arm-nightly godot-binary: godot.macos.editor.dev.arm64 + rust-extra-args: --features itest/upcoming-editor-placeholders # Windows @@ -193,7 +194,7 @@ jobs: os: windows-2025 artifact-name: windows-nightly godot-binary: godot.windows.editor.dev.x86_64.exe - # rust-extra-args: --features godot/api-custom + rust-extra-args: --features itest/upcoming-editor-placeholders # Linux @@ -216,7 +217,7 @@ jobs: os: ubuntu-24.04 artifact-name: linux-nightly godot-binary: godot.linuxbsd.editor.dev.x86_64 - rust-extra-args: --features itest/experimental-threads,itest/codegen-full-experimental,godot/api-custom,godot/serde,itest/register-docs + rust-extra-args: --features itest/experimental-threads,itest/codegen-full-experimental,itest/upcoming-editor-placeholders,godot/api-custom,godot/serde,itest/register-docs hot-reload: api-custom - name: linux-release @@ -254,12 +255,14 @@ jobs: artifact-name: linux-4.3 godot-binary: godot.linuxbsd.editor.dev.x86_64 godot-prebuilt-patch: '4.3' + rust-extra-args: --features itest/upcoming-editor-placeholders # enabled at 4.3 boundary - name: linux-4.2 os: ubuntu-24.04 artifact-name: linux-4.2 godot-binary: godot.linuxbsd.editor.dev.x86_64 godot-prebuilt-patch: '4.2' + rust-extra-args: --features itest/upcoming-editor-placeholders # enabled before 4.3 boundary # Memory checkers (always Linux). diff --git a/check.sh b/check.sh index 877fd267a..237c2fdd1 100755 --- a/check.sh +++ b/check.sh @@ -274,7 +274,7 @@ function cmd_dok() { # By default, disable `codegen-full` to reduce compile times and prevent flip-flopping between # `itest` compilations and `check.sh` runs. Note that this means some runs are different from CI. -extraCargoArgs=("--no-default-features") +extraCargoArgs=("--no-default-features" "--features" "godot/upcoming-editor-placeholders,itest/upcoming-editor-placeholders") cmds=() extraArgs=() apiVersion="" diff --git a/godot-core/Cargo.toml b/godot-core/Cargo.toml index 797690b04..ece93cfaf 100644 --- a/godot-core/Cargo.toml +++ b/godot-core/Cargo.toml @@ -20,6 +20,8 @@ codegen-lazy-fptrs = [ "godot-codegen/codegen-lazy-fptrs", ] double-precision = ["godot-codegen/double-precision"] +# TODO(v0.6): remove this + downstream features; behavior becomes default. +upcoming-editor-placeholders = [] experimental-godot-api = ["godot-codegen/experimental-godot-api"] experimental-threads = ["godot-ffi/experimental-threads", "godot-codegen/experimental-threads"] experimental-wasm-nothreads = ["godot-ffi/experimental-wasm-nothreads"] diff --git a/godot-core/src/classes/class_runtime.rs b/godot-core/src/classes/class_runtime.rs index e329f7060..5cad77891 100644 --- a/godot-core/src/classes/class_runtime.rs +++ b/godot-core/src/classes/class_runtime.rs @@ -314,14 +314,16 @@ where } // Behavior depending on editor state: - if let Some(true) = sys::is_editor_or_unknown() { - // * Editor: class is substituted with a PlaceholderExtensionInstance; null binding is expected -> OK. - // Accessing `bind()`/`bind_mut()` on placeholders would still panic independently of this. - } else { - // * Runtime: null binding is a bug -> panic. - // * Unknown: Godot < 4.4 before InitLevel::Scene; no placeholders exist that early -> panic. + // * Editor (with `upcoming-editor-placeholders`): class substituted by PlaceholderExtensionInstance; null binding expected -> OK. + // Accessing `bind()`/`bind_mut()` on placeholders would still panic, independently of this. + // * Runtime: null binding is a bug -> panic. + // * Unknown: Godot < 4.4 before InitLevel::Scene; no placeholders exist that early -> panic. + let placeholder_ok = + cfg!(feature = "upcoming-editor-placeholders") && sys::is_editor_or_unknown() == Some(true); + if !placeholder_ok { panic!( - "Class {} -- null instance; does the class have a Godot creator function?", + "Class {} -- null instance; does the class have a Godot creator function?\n\ + If used in the editor, make sure to use #[class(tool)].", std::any::type_name::() ); } diff --git a/godot-core/src/obj/gd.rs b/godot-core/src/obj/gd.rs index e64a77e49..5fa3d58e9 100644 --- a/godot-core/src/obj/gd.rs +++ b/godot-core/src/obj/gd.rs @@ -20,7 +20,7 @@ use crate::meta::{ }; use crate::obj::{ Bounds, DynGd, GdDerefTarget, GdMut, GdRef, GodotClass, Inherits, InstanceId, OnEditor, RawGd, - Singleton, WithBaseField, WithSignals, bounds, cap, + WithBaseField, WithSignals, bounds, cap, }; use crate::private::{PanicPayload, callbacks}; use crate::registry::class::try_dynify_object; @@ -215,7 +215,7 @@ where /// /// In the Godot editor, classes that are not marked `#[class(tool)]` are replaced with _placeholder instances_ (Godot 4.3+ "runtime classes"). /// From Godot's perspective the instance still exists, so scenes and script code referring to it do not break, but the Rust side is absent. - #[cfg(feature = "trace")] // itest only; not yet exposed publicly. + #[cfg(all(feature = "trace", feature = "upcoming-editor-placeholders"))] #[doc(hidden)] pub fn is_editor_placeholder(&self) -> bool { self.raw.storage().is_none() @@ -547,7 +547,9 @@ impl Gd { where T: cap::GodotDefault, { - // Behavior of default instance creation -- see also https://github.com/godot-rust/gdext/issues/1404: + // Behavior of default instance creation -- see also https://github.com/godot-rust/gdext/issues/1404. + // + // With `upcoming-editor-placeholders` (future v0.6 default): // * Editor: use ClassDB.instantiate() -> C++ instantiate_internal(). // * Tool class -> Godot creates instance regularly (extra Variant roundtrip, but editor usually not perf-critical). // * Runtime class -> Godot substitutes placeholder instance. @@ -556,17 +558,55 @@ impl Gd { // * Unknown (for Godot < 4.4 && stage < Scene) -> behave like Runtime. // Editor/ClassDb::instantiate path would be correct in all cases, but ClassDB isn't available on all levels. Thus we can only do // the runtime path. It means that if runtime classes are constructed in level < Scene, they will not be placeholdered (rare case). + // + // Without the feature (v0.5-compatible default): editor branch is skipped; all states fall through to the direct `create` callback + // below, returning a real Rust instance even for non-tool classes in the editor. Migration warning below flags the v0.6 change. + #[cfg(feature = "upcoming-editor-placeholders")] if sys::is_editor_or_unknown().unwrap_or(false) { let class_name = T::class_id().to_string_name(); // Note: C API classdb_construct_object[2|3] calls C++ instantiate_no_placeholders(), which skips placeholder substitution. // Instead we use ClassDB.instantiate() -> C++ _instantiate_internal(). + use crate::obj::Singleton as _; let variant = classes::ClassDb::singleton().instantiate(&class_name); return variant.try_to::().unwrap_or_else(|_| { panic!("ClassDB.instantiate({class_name}) failed -- class not registered or not instantiable") }); } + // v0.6 migration: under the legacy path (no `upcoming-editor-placeholders`), `T::new_alloc()` / `T::new_gd()` returns a real Rust + // instance even for non-`#[class(tool)]` classes in the editor. In v0.6 this becomes a placeholder, silently losing Rust-side + // logic (init/ready/...). One warning per class id, then backtrace printed to stderr so user can locate caller. + #[cfg(not(feature = "upcoming-editor-placeholders"))] + let class_id = T::class_id(); + #[cfg(not(feature = "upcoming-editor-placeholders"))] + if sys::is_editor_or_unknown().unwrap_or(false) + && crate::registry::class::is_class_tool(class_id) == Some(false) + { + use std::collections::HashSet; + + // Persists for the process lifetime, including across hot reloads -- one warning per class per process, not per reload. + static WARNED: sys::Global> = sys::Global::default(); + + let is_new = WARNED.lock().insert(class_id); + if is_new { + sys::defer_startup_warn!( + id: "EditorPlaceholderV06", + "godot-rust v0.6 will change editor behavior for non-`#[class(tool)]` runtime classes.\n\ + Class `{class_id}` creation in editor now returns real Rust instance; v0.6 will return a placeholder (details with RUST_BACKTRACE=1).\n\ + Opt in early via the `upcoming-editor-placeholders` feature, or mark the class as `#[class(tool)]` if it runs in the editor.", + ); + + // If RUST_BACKTRACE is set, print backtrace. + let bt = std::backtrace::Backtrace::capture(); + if bt.status() == std::backtrace::BacktraceStatus::Captured { + eprintln!( + "Backtrace for `{class_id}` (v0.6 editor-placeholder migration):\n{bt}" + ); + } + } + } + // Fast path if not running in the editor: bypass substitution and directly call creation func. unsafe { // Default value (and compat one) for `p_notify_postinitialize` is true in Godot. diff --git a/godot-core/src/obj/raw_gd.rs b/godot-core/src/obj/raw_gd.rs index ea91a5b14..36d4598e3 100644 --- a/godot-core/src/obj/raw_gd.rs +++ b/godot-core/src/obj/raw_gd.rs @@ -450,7 +450,7 @@ where self.check_rtti("bind"); let storage = self .storage() - .unwrap_or_else(|| crate::classes::panic_placeholder_bind::("bind")); + .unwrap_or_else(|| classes::panic_placeholder_bind::("bind")); GdRef::from_guard(storage.get()) } @@ -461,7 +461,7 @@ where self.check_rtti("bind_mut"); let storage = self .storage() - .unwrap_or_else(|| crate::classes::panic_placeholder_bind::("bind_mut")); + .unwrap_or_else(|| classes::panic_placeholder_bind::("bind_mut")); GdMut::from_guard(storage.get_mut()) } diff --git a/godot-core/src/registry/class.rs b/godot-core/src/registry/class.rs index dfc47d2bc..0d28c756b 100644 --- a/godot-core/src/registry/class.rs +++ b/godot-core/src/registry/class.rs @@ -45,6 +45,13 @@ fn global_loaded_classes_by_name() -> GlobalGuard<'static, HashMap GlobalGuard<'static, HashMap>> { static DYN_TRAITS_BY_TYPEID: Global>> = Global::default(); @@ -63,10 +70,13 @@ pub struct LoadedClass { unregister_singleton_fn: Option, } -/// Represents a class which is currently loaded and retained in memory -- including metadata. -// -// Currently empty, but should already work for per-class queries. -pub struct ClassMetadata {} +/// Looks up whether a registered Rust class was declared with `#[class(tool)]`. Returns `None` for engine classes (not in our registry). +#[cfg(not(feature = "upcoming-editor-placeholders"))] +pub(crate) fn is_class_tool(class_id: ClassId) -> Option { + global_loaded_classes_by_name() + .get(&class_id) + .map(|m| m.is_tool) +} // ---------------------------------------------------------------------------------------------------------------------------------------------- @@ -104,6 +114,7 @@ struct ClassRegistrationInfo { #[allow(dead_code)] // Currently unused; may be useful for diagnostics in the future. init_level: InitLevel, is_editor_plugin: bool, + is_tool: bool, /// One entry for each `dyn Trait` implemented (and registered) for this class. dynify_fns_by_trait: HashMap, @@ -182,6 +193,7 @@ pub(crate) fn register_class< godot_params, init_level: T::INIT_LEVEL, is_editor_plugin: false, + is_tool: false, dynify_fns_by_trait: HashMap::new(), component_already_filled: Default::default(), // [false; N] register_singleton_fn: None, @@ -279,7 +291,9 @@ fn register_classes_and_dyn_traits( is_editor_plugin: info.is_editor_plugin, unregister_singleton_fn: info.unregister_singleton_fn, }; - let metadata = ClassMetadata {}; + let metadata = ClassMetadata { + is_tool: info.is_tool, + }; // Transpose Class->Trait relations to Trait->Class relations. for (trait_type_id, mut dyn_trait_impl) in info.dynify_fns_by_trait.drain() { @@ -453,6 +467,7 @@ fn fill_class_info(item: PluginItem, c: &mut ClassRegistrationInfo) { c.default_virtual_fn = default_get_virtual_fn; c.register_properties_fn = Some(register_properties_fn); c.is_editor_plugin = is_editor_plugin; + c.is_tool = is_tool; c.register_singleton_fn = register_singleton_fn; c.unregister_singleton_fn = unregister_singleton_fn; @@ -710,6 +725,7 @@ fn default_registration_info(class_name: ClassId) -> ClassRegistrationInfo { godot_params: default_creation_info(), init_level: InitLevel::Scene, is_editor_plugin: false, + is_tool: false, dynify_fns_by_trait: HashMap::new(), component_already_filled: Default::default(), // [false; N] } diff --git a/godot-ffi/src/lib.rs b/godot-ffi/src/lib.rs index 2863d69f4..145795297 100644 --- a/godot-ffi/src/lib.rs +++ b/godot-ffi/src/lib.rs @@ -127,6 +127,11 @@ static MAIN_THREAD_ID: ManualInitCell = ManualInitCell::n /// Warnings/errors collected during startup, and deferred until editor UI is ready. static STARTUP_MESSAGES: Global> = Global::default(); +/// Set to `true` after [`print_deferred_startup_messages`] has flushed once. From then on, new messages are printed directly instead +/// of queued, so warnings/errors emitted later are not silently dropped. +static STARTUP_MESSAGES_FLUSHED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + /// A message to be displayed in the Godot editor once UI is ready. struct StartupMessage { message: std::ffi::CString, @@ -354,7 +359,34 @@ pub fn collect_startup_message( level, }; - STARTUP_MESSAGES.lock().push(msg); + // Check the flushed-flag while holding the lock: otherwise, a concurrent flush could drain and set the flag between our load and push, + // leaving the message in the queue with no further flush to deliver it. Holding the lock serializes us against the flusher. + let mut messages = STARTUP_MESSAGES.lock(); + if STARTUP_MESSAGES_FLUSHED.load(std::sync::atomic::Ordering::Acquire) { + drop(messages); + print_message(&msg); + } else { + messages.push(msg); + } +} + +/// Print a single message to the editor UI via the FFI interface. +fn print_message(msg: &StartupMessage) { + let print_fn = match msg.level { + StartupMessageLevel::Warn { .. } => interface_fn!(print_warning), + StartupMessageLevel::Error => interface_fn!(print_error), + }; + + // SAFETY: The binding has been initialized, so we can use interface functions. + unsafe { + print_fn( + msg.message.as_ptr(), + msg.function.as_ptr(), + msg.file.as_ptr(), + msg.line, + conv::SYS_TRUE, // Notify editor. + ); + } } /// Check if a message ID is suppressed via the `GDRUST_SUPPRESSED_WARNINGS` environment variable. @@ -369,32 +401,20 @@ fn is_message_suppressed(id: &str) -> bool { } /// Flush all deferred messages to the Godot editor. Called during `MainLoop` initialization, when editor UI is ready. +/// +/// After this returns, [`collect_startup_message`] switches to direct printing so late-firing sites +/// (property accessors, `_ready` callbacks, etc.) are not silently dropped. pub fn print_deferred_startup_messages() { let mut messages = STARTUP_MESSAGES.lock(); - if messages.is_empty() { - return; - } - for msg in messages.iter() { - let print_fn = match msg.level { - StartupMessageLevel::Warn { .. } => interface_fn!(print_warning), - StartupMessageLevel::Error => interface_fn!(print_error), - }; - - // SAFETY: The binding has been initialized, so we can use interface functions. - unsafe { - print_fn( - msg.message.as_ptr(), - msg.function.as_ptr(), - msg.file.as_ptr(), - msg.line, - conv::SYS_TRUE, // Notify editor. - ); - } + print_message(msg); } - messages.clear(); + + // Flip flag while holding the lock, so any collector racing with us either pushes before us + // (and gets flushed above) or sees the flag set (and prints directly). Either path delivers. + STARTUP_MESSAGES_FLUSHED.store(true, std::sync::atomic::Ordering::Release); } fn print_preamble(version: GDExtensionGodotVersion) { @@ -729,10 +749,11 @@ macro_rules! interface_fn { // ---------------------------------------------------------------------------------------------------------------------------------------------- // Deferred editor message macros -/// Store a warning for deferred display in Godot editor UI. +/// Store a warning for display in Godot editor UI. /// -/// Captured during startup, displayed at `MainLoop` init. Will be visible in Godot editor's _Output_ tab. -/// Warnings can be suppressed via the `GDRUST_SUPPRESSED_WARNINGS` environment variable. +/// Messages appear in the Godot editor's _Output_ tab: queued before `MainLoop` init and flushed +/// once the UI is ready, then printed immediately for late-firing sites (property accessors, +/// `_ready` callbacks, etc.). Suppressible via the `GDRUST_SUPPRESSED_WARNINGS` environment variable. /// /// # Example /// ```no_run @@ -756,10 +777,10 @@ macro_rules! defer_startup_warn { }}; } -/// Store an error for deferred display in Godot editor UI. +/// Store an error for display in Godot editor UI. /// -/// Captured during startup, displayed at `MainLoop` init. Will be visible in Godot editor's _Output_ tab. -/// Errors cannot be suppressed. +/// Messages appear in the Godot editor's _Output_ tab: queued before `MainLoop` init and flushed +/// once the UI is ready, then printed immediately for late-firing sites. Errors cannot be suppressed. /// /// # Example /// ```no_run diff --git a/godot/Cargo.toml b/godot/Cargo.toml index 8ea1af1bc..dcde2658c 100644 --- a/godot/Cargo.toml +++ b/godot/Cargo.toml @@ -17,6 +17,7 @@ readme = "crate-readme.md" custom-godot = ["api-custom"] custom-json = ["api-custom-json"] double-precision = ["godot-core/double-precision"] +upcoming-editor-placeholders = ["godot-core/upcoming-editor-placeholders"] experimental-godot-api = ["godot-core/experimental-godot-api"] experimental-threads = ["godot-core/experimental-threads"] experimental-wasm = [] diff --git a/godot/src/lib.rs b/godot/src/lib.rs index e97bd010d..fc5e44a4e 100644 --- a/godot/src/lib.rs +++ b/godot/src/lib.rs @@ -128,6 +128,11 @@ //! Use `f64` instead of `f32` for the floating-point type [`real`][type@builtin::real]. Requires Godot to be compiled with the //! scons flag `precision=double`.

//! +//! * **`upcoming-editor-placeholders`** +//! +//! Opt-in support for inspecting editor placeholder instances of non-`#[class(tool)]` classes (Godot 4.3+ runtime classes). +//! Useful when editor-side code needs to distinguish placeholders from fully-attached Rust instances. Will become the default in v0.6.

+//! //! * **`experimental-godot-api`** //! //! Access to `godot::classes` APIs that Godot marks "experimental". These are under heavy development and may change at any time. diff --git a/itest/rust/Cargo.toml b/itest/rust/Cargo.toml index 41fcc0a9d..030e75ada 100644 --- a/itest/rust/Cargo.toml +++ b/itest/rust/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib"] default = [] codegen-full = ["godot/__codegen-full"] codegen-full-experimental = ["codegen-full", "godot/experimental-godot-api"] +upcoming-editor-placeholders = ["godot/upcoming-editor-placeholders"] experimental-threads = ["godot/experimental-threads"] register-docs = ["godot/register-docs"] serde = ["dep:serde", "dep:serde_json", "godot/serde"] diff --git a/itest/rust/src/editor_test/mod.rs b/itest/rust/src/editor_test/mod.rs index 1828d79a5..776bb5b07 100644 --- a/itest/rust/src/editor_test/mod.rs +++ b/itest/rust/src/editor_test/mod.rs @@ -6,13 +6,13 @@ */ // Placeholder substitution for runtime (non-tool) classes is only meaningful since Godot 4.3. -#[cfg(since_api = "4.3")] +#[cfg(all(since_api = "4.3", feature = "upcoming-editor-placeholders"))] mod editor_placeholder_test; mod editor_general_test; /// On Godot < 4.3, placeholder substitution does not exist; `is_editor_placeholder()` always returns `false`. -#[cfg(before_api = "4.3")] +#[cfg(all(before_api = "4.3", feature = "upcoming-editor-placeholders"))] #[crate::framework::itest(editor)] fn editor_pre_4_3_no_placeholders() { use godot::obj::NewGd;