Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/full-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/minimal-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,15 @@ 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

- name: windows
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

Expand All @@ -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
Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
2 changes: 2 additions & 0 deletions godot-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
16 changes: 9 additions & 7 deletions godot-core/src/classes/class_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>()
);
}
Expand Down
46 changes: 43 additions & 3 deletions godot-core/src/obj/gd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -547,7 +547,9 @@ impl<T: GodotClass> Gd<T> {
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.
Expand All @@ -556,17 +558,55 @@ impl<T: GodotClass> Gd<T> {
// * 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::<Self>().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<HashSet<ClassId>> = 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.
Expand Down
4 changes: 2 additions & 2 deletions godot-core/src/obj/raw_gd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ where
self.check_rtti("bind");
let storage = self
.storage()
.unwrap_or_else(|| crate::classes::panic_placeholder_bind::<T>("bind"));
.unwrap_or_else(|| classes::panic_placeholder_bind::<T>("bind"));
GdRef::from_guard(storage.get())
}

Expand All @@ -461,7 +461,7 @@ where
self.check_rtti("bind_mut");
let storage = self
.storage()
.unwrap_or_else(|| crate::classes::panic_placeholder_bind::<T>("bind_mut"));
.unwrap_or_else(|| classes::panic_placeholder_bind::<T>("bind_mut"));
GdMut::from_guard(storage.get_mut())
}

Expand Down
26 changes: 21 additions & 5 deletions godot-core/src/registry/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ fn global_loaded_classes_by_name() -> GlobalGuard<'static, HashMap<ClassId, Clas
lock_or_panic(&LOADED_CLASSES_BY_NAME, "loaded classes (by name)")
}

/// Represents a class which is currently loaded and retained in memory -- including metadata.
pub struct ClassMetadata {
// Only read on the legacy (feature-off) path via `is_class_tool`; under `upcoming-editor-placeholders` the field is set but unused.
#[cfg_attr(feature = "upcoming-editor-placeholders", allow(dead_code))]
pub is_tool: bool,
}

fn global_dyn_traits_by_typeid() -> GlobalGuard<'static, HashMap<any::TypeId, Vec<DynTraitImpl>>> {
static DYN_TRAITS_BY_TYPEID: Global<HashMap<any::TypeId, Vec<DynTraitImpl>>> =
Global::default();
Expand All @@ -63,10 +70,13 @@ pub struct LoadedClass {
unregister_singleton_fn: Option<fn()>,
}

/// 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<bool> {
global_loaded_classes_by_name()
.get(&class_id)
.map(|m| m.is_tool)
}

// ----------------------------------------------------------------------------------------------------------------------------------------------

Expand Down Expand Up @@ -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<any::TypeId, DynTraitImpl>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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]
}
Expand Down
Loading
Loading