February 2024 dev update
Two months have passed since the last update, and it's time to share some progress in gdext. The highlights are split into ergonomic improvements and new features, but in some cases the line is blurry.
Ergonomic improvements
Re-entrant calls with base()
and base_mut()
2024 started with a massive improvement: the ability to avoid double-borrow errors in many situations, in PR #501. To understand the significance behind this, let's look at this example:
#[godot_api]
impl INode for MyClass {
fn ready(&mut self) {
let child = Node::new_alloc();
self.base.add_child(child.upcast());
}
fn on_notification(&mut self, what: NodeNotification) {}
}
Adding a child will trigger a NodeNotification::ChildOrderChanged
to be emitted. This calls on_notification
inside ready()
, during the add_child
call.
In most languages, such code is not problematic, but Rust is very strict about borrowing. Since we already hold an exclusive reference &mut self
in ready()
, we cannot get another one for the receiver of on_notification()
-- the &mut self
signature requests one. The call goes via Godot, so the Rust compiler cannot know that add_child()
indirectly calls on_notification()
.
Internally, we used RefCell
with dynamic borrowing, which then caused a double-borrow error at runtime. There is no easy way around it; several approaches were considered:
- Changing signature of all methods from
&mut self
to&self
. Shared references can coexist, but they force interior mutability onto the user, which is very inconvenient if it has to be done for all classes. - Using a
this: Gd<Self>
receiver, which is explicitly bound withGd::bind()
orGd::bind_mut()
. This may solve some issues, but won't work if both methods need mutable access toSelf
. This would come with workarounds such as deferring changes. - Using
unsafe
-- while it might work in typical cases, it's very easy to accidentally introduce UB by aliasing mutable references. It also defeats a lot of Rust's guarantees.
Fortunately, lilizoey came up with a great solution: a custom cell type in place of RefCell
, which allowed to "reborrow" self
as long as the previous exclusive reference is unreachable. This follows the semantics of Rust's own reborrowing when calling functions:
fn f(i: &mut i32) {
// Here, we have an exclusive reference to the integer.
g(i)
}
fn g(i: &mut i32) {
// Here, we _also_ have an exclusive reference to _same_ integer.
// Even though both technically coexist, they don't alias in the Rust
// sense, as the one in f() is not accessible from here.
}
This principle was generalized to calls across the FFI boundary. The resulting code is well-covered by miri
tests for safety.
For user code, the main thing that changes is that instead of self.base
, one needs to use self.base()
and self.base_mut()
. In the above example, it thus becomes possible to have on_notification()
running while ready()
is still active! All the magic happens behind the scenes.
Explicitly disabled constructors
Since the absence of #[class(init)]
could mean either "no default constructor" or "manually defined init
", this regularly led to problems. Users forgot to specify this attribute, which disabled some Godot functionality (e.g. instantiation as nodes or hot reloading).
A new API has been added in #593, which adds the #[class(no_init)]
key to opt out of default constructors:
#[derive(GodotClass)]
#[class(no_init, base=Node)]
struct MyClass {
...
}
The GDScript expression MyClass.new()
will now fail with a descriptive error message. Classes must declare either of #[class(init)]
, #[class(no_init)]
or a manual init()
constructor, otherwise the code will not compile.
No more #[base]
attribute
#[derive(GodotClass)]
pub struct Hud {
#[base]
base: Base<RefCounted>,
}
can now be written as:
#[derive(GodotClass)]
pub struct Hud {
base: Base<RefCounted>,
}
See #577 for details.
ToGodot
and FromGodot
derive improvements
Derive implementations for GodotConvert
, ToGodot
and FromGodot
are undergoing an incremental rewrite, so far primarily in #595 and #599. As a recap, these traits are primarily used for argument and return value passing in #[func]
functions, but also affect related traits like Var
(to implement #[var]
properties). In other words, this part of the API determines how Rust values are mapped to GDScript and back.
#[derive(GodotConvert)]
implements now both ToGodot
and FromGodot
by default, and the attribute #[godot]
can be used to customize conversion:
// Implement ToGodot + FromGodot for MyOwnVector2, by just using the inner type.
#[derive(GodotConvert)]
#[godot(transparent)]
struct MyOwnVector2(Vector2);
// Implement ToGodot + FromGodot for MyEnum, by representing values as i64 integers.
#[derive(GodotConvert)]
#[godot(via = i64)]
enum MyEnum { A, B, C } // mapped as 0, 1, 2
// The same as above, but mapping values as strings.
#[derive(GodotConvert)]
#[godot(via = GString)]
enum MyEnum { A, B, C } // mapped as "A", "B", "C"
This rewrite is still ongoing, we plan to add more customization in #[godot]
. However, it is explicitly not a goal to reinvent a serde-like serialization mechanism with extreme proc-macro customization; if that is desired, we could think about improving our existing serde interop.
Function call diagnostics
In Godot, certain functions have official fail states, meaning the GDExtension API can signal when an invocation fails. For example, Object::call()
is such a function. It can fail when the specified method does not exist, the passed arguments cannot be mapped to the declared parameters, or the target panics (if it's a Rust function).
So far, it was not possible to catch such errors. gdext would simply print an error message, but even panics from Rust were swallowed. This was necessary because panics are not permitted to cross the FFI boundary; GDScript wouldn't know what to begin with it. However, when calling from Rust, we can now do better.
Since today, we can invoke dynamic calls in two ways:
let node: Gd<Node> = ...;
// Panicking call -- this panics since get_position() does not take any arguments.
let v: Variant = node.call("get_position", &[123.to_variant()]);
// Result-based: get Err(CallError) instead of panic. Allows to react to error.
let v: Result<Variant, CallError> = node.try_call("get_position", &[123.to_variant()]);
if let Err(e) = v {
// e (CallError) contains the error message and an API to
// retrieve the affected class/method.
godot_print!("Error: {e}");
}
This not only brings Godot errors to Rust, but also fixes a few cases that previously ran into UB (e.g. #604, if argument types mismatched).
New Godot features
HSV Colors and named constants
In #605, StatisMike added a new class ColorHsv
which extends a lot of Color
's RGB functionality to the HSV color space. It operates directly on HSVA and provides a highly efficient and easy way to directly affect hue, saturation and value. The two types can be seamlessly converted:
let mut color = ColorHsv::from_hsv(0.8, 0.6, 0.25);
// Rotate hue on the "wheel" by 90° (0.25 units of a full circle).
color.h += 0.25; // h is now 1.05.
// Normalize to 0..1 range by wrapping (alternative: clamping).
color = normalized_wrapped_h(); // h is now 0.05.
// Convert back to RGB.
let rgb: Color = color.to_rgb();
Furthermore, fpdotmonkey added color constants in #601. It's now possible to access values like Color::LIGHT_STEEL_BLUE
, also in const
contexts.
Virtual script functions
Pull request #606 adds support for script-based polymorphism via virtual methods.
If you have a Rust method greet
, you can now declare it as virtual:
#[godot_api]
impl MyClass {
#[func(virtual)]
fn greet(&self) -> GString {
"Rust".into()
}
}
Then, you can attach a script to an instance of MyClass
and override this method. This works for all sorts of scripts, but most common is GDScript:
extends MyClass
func _greet() -> String:
return "GDScript"
Calling self.greet()
in Rust will now directly return either "Rust"
(if no script is attached) or "GDScript"
(if a script is attached).
Native tool classes
So far, #[class(tool)]
could be used to emulate "tool classes". This was a gdext-specific mechanism which disabled lifecycle callbacks like ready()
or process()
in the editor. This feature has been implemented far before Godot support due to popular demand (see #70).
With the upcoming Godot 4.3, Godot offers native support for "runtime classes" (upstream #82552). Runtime classes are the opposite of tool classes, i.e. classes whose code only runs after launching the game, not in the editor.
We conditionally switch our implementation to use Godot's native feature starting from API level 4.3 (PR #619). This should come with small benefits like placeholder classes. Behavior might thus slightly change; as the feature is new, it could also come with initial bugs.
Translation macros
Godot offers the Object
methods tr()
and tr_n()
, which can be used to translate strings. While they were already exposed via the Object
API, they are rather cumbersome to use from Rust.
In PR #596, vortexofdoom added two macros that make usage more idiomatic:
// Simple translation:
tr!("{a} is a {b}"); // with interpolation
tr!(context; "{a} is a {b}"); // with translation context
// Pluralized translation:
tr_n!(n; "{0} is a {1}", "{0}s are {1}s", a, b);
// ^ singular ^ plural
// ^ cardinality (decides if singular or plural is used)
Bugfixes and tooling improvements
2024 has come with dozens of changes not mentioned here, which include bugfixes, documentation improvements, and other small features. To get an exhaustive list, check the recently merged GitHub pull requests.