-
-
Notifications
You must be signed in to change notification settings - Fork 224
Type-safe API for external classes (GDScript, GDExtension, C#) #372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Hello! 🙂 We haven't really tested this scenario yet. At the moment, "external classes" are not accessible in a type-safe way, but you can use the reflection API In the future, what I could imagine would be a feature similar to the proposal in godot-rust/gdnative#200: an explicit way to declare external APIs. This should probably work for GDScript and other GDExtensions likewise. |
So, I was giving this issue some thought, and wanted to share some ideas. For example, if you have a GDScript class named "Something" and you have methods something(arg1), somethingsomething(arg2) , then, instead of accepting it as a Variant or Object or something else everywhere, and manually calling methods through #[derive(bikeshed::ExternClass)]
#[class(base = RefCounted)] // or something
struct Something; // macro would probs add some opaque fields
#[godot_api(bikeshed::extern)] // defaults to using the struct name as class
// #[godot_api(bikeshed::extern(rename = OtherClass))] idk
impl Something {
// perhaps some const or something too
#[func]
fn something(arg1: Variant /* or some more specific type */) -> Variant;
#[func(rename = somethingsomething)]
fn something_something(arg2: i32 /* only expect ints */) -> bool; /* you're sure only bool is returned */
} (Usage of Alternatively, a trait could be used instead of Several points to consider
Feel free to add more questions here 🙂 |
I'm strongly in favor of painting the ok nah but seriously, I think that part about codegen for non-native stuff would be pretty interesting, but I have no idea how codegen works for this project already so kinda don't know how hard (or impossible?) that'd be to even implement (safe|ergonomical)ly. |
Apparently, if we run Godot with other extensions enabled and then run So to some extent, this may be possible to achieve today with the IIRC the TLDR, I see a few ways forward:
But at this point, I don't see much than gdext can do apart from implementing 3). |
I originally posted this in #724. An alternative idea to what has been posted so far could be to allow the user to define traits for the foreign interfaces they expect, and then provide a proc-macro that generates a duck-type implementation against // ------------- types and traits provided by gdext -------------
/// Wrapper around Gd<T> on which traits will be implemented. This type cannot be constructed or
/// obtained by the consumer.
pub struct ForeignClass(Gd<Object>);
impl ForeignClass {
/// runtime validation of function signatures. could also take a full MethodInfo.
pub fn has_method(&self, name: StringName, args: &[VariantType], ret: VariantType) -> bool {
let method = self.0.get_method_list().iter_shared().find(|method| {
method
.get("name")
.map(|method| method.to::<StringName>() == name)
.unwrap_or(false)
});
let Some(method_args) = method.as_ref().and_then(|method| method.get("args")) else {
return false;
};
let method_args = method_args.to::<Array<Dictionary>>();
let matches = args
.iter()
.enumerate()
.map(|(index, arg)| {
method_args
.try_get(index)
.map(|m_arg| &m_arg.get_or_nil("type").to::<VariantType>() == arg)
.unwrap_or(false)
})
.all(|item| item);
let return_matches = method
.and_then(|method| method.get("return"))
.map(|r| r.to::<VariantType>() == ret)
.unwrap_or(false);
matches && return_matches
}
/// pass through to Object::call
pub fn call(&mut self, name: StringName, args: &[Variant]) -> Variant {
self.0.call(name, args)
}
}
/// helper trait for casting the foreign class into a trait object.
pub trait ForeignClassObject {
type Base: GodotClass;
fn from_foreign(fc: Box<ForeignClass>) -> Result<Box<Self>, ForeignClassError>;
}
/// extension trait for Gd, can be merged into the Gd impl.
pub trait GdExt<T: GodotClass> {
fn try_to_foreign<O>(&self) -> Result<Box<O>, ForeignClassError>
where
O: ForeignClassObject + ?Sized,
T: Inherits<O::Base> + Inherits<Object>;
}
impl<T: GodotClass> GdExt<T> for Gd<T> {
/// cast into a duck-typed trait object. The compatibility is checked at runtime.
/// This is the only way to get an instance of ForeignClass.
fn try_to_foreign<O>(&self) -> Result<Box<O>, ForeignClassError>
where
O: ForeignClassObject + ?Sized,
T: Inherits<O::Base> + Inherits<Object>,
{
let obj = self.clone().upcast();
let foreign = Box::new(ForeignClass(obj));
/// compatebility is currently checked inside this function but could be moved into a separate call.
O::from_foreign(foreign)
}
}
#[derive(Debug)]
pub enum ForeignClassError {
MissingMethod(StringName, Vec<VariantType>, VariantType),
}
// ------------- trait and impls inside the consumer gdextension -------------
/// user declared foreign interface
trait ITestScript {
fn health(&mut self) -> u8;
fn hit_enemy(&mut self, enemy: Gd<Node3D>);
}
/// proc-macro generates an implementation of the trait for ForeignClass by calling its methods via Object::class
impl ITestScript for ForeignClass {
fn health(&mut self) -> u8 {
self.call(StringName::from("health"), &[]).to()
}
fn hit_enemy(&mut self, enemy: Gd<Node3D>) {
self.call(StringName::from("hit_enemy"), &[enemy.to_variant()])
.to()
}
}
/// implementation of the helper trait to cast a Box<ForeignClass> into the correct trait object.
impl ForeignClassObject for dyn ITestScript {
type Base = Node3D;
fn from_foreign(fc: Box<ForeignClass>) -> Result<Box<Self>, ForeignClassError> {
// validate health method exists and is correct
if !fc.has_method("health".into(), &[], VariantType::Int) {
return Err(ForeignClassError::MissingMethod(
"health".into(),
vec![],
VariantType::Int,
));
}
// validate hit_enemy method exists and is correct
if !fc.has_method("hit_enemy".into(), &[VariantType::Object], VariantType::Nil) {
return Err(ForeignClassError::MissingMethod(
"hit_enemy".into(),
vec![VariantType::Object],
VariantType::Nil,
));
}
// cast once everything has been verified.
Ok(fc as Box<Self>)
}
} |
I also think this is the way to go, it's conceptually similar to godot-rust/gdnative#200 (linked above) but with attribute instead of function-like proc-macros. The important point here is that the same mechanism can be used for GDScript, C# and other GDExtension bindings. Ideally we can keep foreign types as close as possible to native ones, e.g. when it comes to using them within So, I'm not sure if the user-facing API should really be a trait -- We also need to consider that there are different symbols that a class may expose:
And in an ideal world, declaring a foreign/external class is similar to declaring an own type, so the constructs are not entirely different. // Syntax subject to change, just illustrating idea
#[derive(GodotClass)]
#[class(foreign, base=Node3D)] // Base must be known. Also, what if it's another foreign class?
struct TestScript {
// Properties:
#[var]
name: GString, // generates set_name, get_name
// Signals:
#[signal]
on_attack: Signal<...>, // syntax TBD
}
#[godot_api(foreign)]
impl TestScript {
// Functions:
#[func]
fn health(&mut self) -> u8;
#[func]
fn hit_enemy(&mut self, enemy: Gd<Node3D>);
// Constants:
#[constant]
const DEFAULT_HP: i32;
} Or, with trait: #[godot_api(foreign)]
trait TestScript {
// Some way to declare properties/signals, if there is no struct?
// Functions:
fn health(&mut self) -> u8;
fn hit_enemy(&mut self, enemy: Gd<Node3D>);
// Constants:
const DEFAULT_HP: i32;
} It might also be worthwhile to require |
From my mediocre understanding, it looks like defining foreign types could be defined pretty much the same way as engine types, only using a slightly different way of dispatching the method calls. I just find it strange to declare a struct and impl block that does not have function bodies. It looks like a trait, but it's not, which makes it quite alien. I completely get the desire to make it fit in with the engine declared and user declared types, though.
If base is another foreign type it could either be used, if it has been defined in rust, or any of the engine types in its inheritance chain could be used if the foreign base is not defined. That shouldn't really matter for interfacing with the class and only affect how it can be casted.
I think this should be kept in line with how the generated engine types work. Currently, that would be everything is a method and e.g. properties need a getter and setter method. |
Is there even a difference between foreign and engine types, so far as gdext is concerned? If they're both being generated from extension_api.json and Godot is able to provide all the pointers to useful functions, it doesn't seem they're much different beyond provenance. Maybe they could be generated into their own module, perhaps |
Not that I'm aware of. I don't know the specifics of Godot's C# integration, but I'd expect function calls routed through the engine to just work, independently of the language they're defined in 🙂 If we can generate code, a dedicated module sounds like a great idea, however I'm not sure if the JSON exposes which extension a certain class comes from 🤔 |
Foreign types are not necessarily part of the |
It doesn't, example from a gdext class that shows up in the JSON: {
"name": "TerrainBuilderFactory",
"is_refcounted": true,
"is_instantiable": true,
"inherits": "RefCounted",
"api_type": "extension",
"methods": [
{
"name": "create",
"is_const": true,
"is_vararg": false,
"is_static": false,
"is_virtual": false,
"hash": 997836395,
"return_value": {
"type": "TerrainBuilder"
},
"arguments": [
{
"name": "tilelist",
"type": "Dictionary"
},
{
"name": "rotation",
"type": "TerrainRotation"
},
{
"name": "materials",
"type": "Dictionary"
}
]
}
]
} |
I don't think this matters per se; if you're already using Re: extension_api.json issues, this seems a deficiency of upstream that would be relatively easy to solve. It already says that it's an extension ( |
But now an additional idea: could this be done without |
I agree, such metadata would be very useful. Either a name that an extension itself declares, or the file name of the
Very good point about the scripts. I don't think As I see it, there are two ways to query Godot for the available APIs:
For both cases, the Godot binary is needed for codegen (like in There is also the related issue: |
There's also an option for build-time reflection by creating a headless Godot app that that writes the API of an extension named from an arg or stdin to stdout. |
How would I use a class that was defined in other GDExtensions? For example, I want to use this GDExtension, but I don't know how I would create the
Wasm
class. Can I even do this?The text was updated successfully, but these errors were encountered: