Skip to content

Commit ad11cfe

Browse files
committed
Allow users to generate API from user-provided JSON and headers
- Implement codegen from user-provided json (`godot_json.rs`, similar to `godot_exe.rs`) - Add `linux-custom-api-json` job to pipeline - Use the latest GDExtension headers by default, but allow users to specify their own in case if they are using their own, modified version of the engine. - Allow to use `double-precision` feature with an `api-custom-json`.
1 parent f883763 commit ad11cfe

File tree

17 files changed

+276
-85
lines changed

17 files changed

+276
-85
lines changed

.github/composite/godot-itest/action.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ name: godot
77
description: "Run Godot integration tests"
88

99
inputs:
10+
godot-indirect-json:
11+
required: false
12+
default: ''
13+
description: "If set to true, compiles godot-rust with the `api-custom-json` feature against the generated JSON file"
14+
1015
artifact-name:
1116
required: true
1217
description: "Name of the compiled Godot artifact to download"
@@ -125,9 +130,18 @@ runs:
125130
126131
shell: bash
127132

133+
# else if
134+
- name: "Dump extension api"
135+
if: inputs.godot-indirect-json == 'true' && inputs.godot-prebuilt-patch == ''
136+
run: |
137+
$GODOT4_BIN --headless --dump-extension-api
138+
mv extension_api.json $RUNNER_DIR/godot_bin/extension_api.json
139+
echo "GODOT4_GDEXTENSION_JSON=$RUNNER_DIR/godot_bin/extension_api.json" >> $GITHUB_ENV
140+
shell: bash
141+
128142
# else
129143
- name: "No patch selected"
130-
if: inputs.godot-prebuilt-patch == ''
144+
if: inputs.godot-prebuilt-patch == '' && inputs.godot-indirect-json != 'true'
131145
run: |
132146
echo "No patch selected; use default godot4-prebuilt version."
133147
shell: bash

.github/workflows/full-ci.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,15 @@ jobs:
333333
# Important to keep both experimental-threads and codegen-full. Some itests (native_st_audio) require both.
334334
rust-extra-args: --features itest/experimental-threads,itest/codegen-full-experimental,godot/api-custom,godot/serde,itest/register-docs
335335

336+
# Compiles godot-rust with `api-custom-json` feature against the JSON file generated via `--dump-extension-api`.
337+
# Uses latest 4.x headers, while `extension_api.json` comes from the latest Godot binary.
338+
- name: linux-custom-api-json
339+
os: ubuntu-22.04
340+
artifact-name: linux-nightly
341+
godot-binary: godot.linuxbsd.editor.dev.x86_64
342+
rust-extra-args: --features godot/api-custom-json
343+
godot-indirect-json: true
344+
336345
- name: linux-release
337346
os: ubuntu-22.04
338347
artifact-name: linux-release-nightly
@@ -406,7 +415,8 @@ jobs:
406415
rust-cache-key: ${{ matrix.rust-cache-key }}
407416
with-llvm: ${{ contains(matrix.name, 'macos') && contains(matrix.rust-extra-args, 'api-custom') }}
408417
godot-check-header: ${{ matrix.godot-check-header }}
409-
418+
godot-indirect-json: ${{ matrix.godot-indirect-json }}
419+
410420
- name: "Build and test hot-reload"
411421
if: ${{ matrix.with-hot-reload }}
412422
working-directory: itest/hot-reload/godot

.github/workflows/minimal-ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ jobs:
161161
rust-extra-args: --features itest/codegen-full
162162
with-hot-reload: true
163163

164+
- name: linux-custom-api-json
165+
os: ubuntu-22.04
166+
artifact-name: linux-nightly
167+
godot-binary: godot.linuxbsd.editor.dev.x86_64
168+
rust-extra-args: --features godot/api-custom-json
169+
godot-indirect-json: true
170+
164171
- name: linux-features-experimental
165172
os: ubuntu-22.04
166173
artifact-name: linux-nightly
@@ -221,6 +228,7 @@ jobs:
221228
rust-target: ${{ matrix.rust-target }}
222229
with-llvm: ${{ contains(matrix.name, 'macos') && contains(matrix.rust-extra-args, 'api-custom') }}
223230
godot-check-header: ${{ matrix.godot-check-header }}
231+
godot-indirect-json: ${{ matrix.godot-indirect-json }}
224232

225233
- name: "Build and test hot-reload"
226234
if: ${{ matrix.with-hot-reload }}

godot-bindings/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ api-4-4 = []
3434
default = []
3535

3636
api-custom = ["dep:bindgen", "dep:regex", "dep:which"]
37+
api-custom-json = ["dep:nanoserde", "dep:bindgen", "dep:regex", "dep:which"]
3738
api-custom-extheader = []
3839

3940
[dependencies]
@@ -42,6 +43,8 @@ gdextension-api = { workspace = true }
4243
bindgen = { workspace = true, optional = true }
4344
regex = { workspace = true, optional = true }
4445
which = { workspace = true, optional = true }
46+
# required by `api-custom-json` to parse the extension api header (to get the Godot version).
47+
nanoserde = { workspace = true, optional = true }
4548

4649
[dev-dependencies]
4750
# For tests, we need regex unconditionally.

godot-bindings/build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
fn main() {
1414
let mut count = 0;
1515
if cfg!(feature = "api-custom") { count += 1; }
16+
if cfg!(feature = "api-custom-json") { count += 1; }
1617

1718
// [version-sync] [[
1819
// [line] \tif cfg!(feature = "api-$kebabVersion") { count += 1; }

godot-bindings/src/godot_exe.rs

Lines changed: 7 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77

88
//! Commands related to Godot executable
99
10-
use crate::godot_version::parse_godot_version;
11-
use crate::header_gen::generate_rust_binding;
10+
use crate::godot_version::{parse_godot_version, validate_godot_version};
11+
use crate::header_gen::{generate_rust_binding, patch_c_header};
1212
use crate::watch::StopWatch;
1313
use crate::GodotVersion;
1414

15-
use regex::Regex;
1615
use std::fs;
1716
use std::path::{Path, PathBuf};
1817
use std::process::{Command, Output};
@@ -55,8 +54,7 @@ pub fn write_gdextension_headers(
5554
is_h_provided: bool,
5655
watch: &mut StopWatch,
5756
) {
58-
// None=(unknown, no engine), Some=(version of Godot). Later verified by header itself.
59-
// Even though we don't support 4.0 anymore, we still detect it, for better error messages.
57+
// Use Godot binary to dump GDExtension headers if they weren't provided by the user.
6058
if !is_h_provided {
6159
// No external C header file: Godot binary is present, we use it to dump C header
6260
let godot_bin = locate_godot_binary();
@@ -78,7 +76,7 @@ pub fn write_gdextension_headers(
7876
// Listening to changes on files that are generated by this build step cause an infinite loop with cargo watch of
7977
// build -> detect change -> rebuild -> detect change -> ...
8078
// rerun_on_changed(inout_h_path);
81-
patch_c_header(inout_h_path);
79+
patch_c_header(inout_h_path, inout_h_path);
8280
watch.record("patch_header_h");
8381

8482
generate_rust_binding(inout_h_path, out_rs_path);
@@ -111,28 +109,9 @@ pub(crate) fn read_godot_version(godot_bin: &Path) -> GodotVersion {
111109
let output = execute(cmd, "read Godot version");
112110
let stdout = std::str::from_utf8(&output.stdout).expect("convert Godot version to UTF-8");
113111

114-
let version = match parse_godot_version(stdout) {
115-
Ok(parsed) => {
116-
assert_eq!(
117-
parsed.major,
118-
4,
119-
"Only Godot versions with major version 4 are supported; found version {}.",
120-
stdout.trim()
121-
);
122-
123-
assert!(
124-
parsed.minor > 0,
125-
"Godot 4.0 is no longer supported by godot-rust; found version {}.",
126-
stdout.trim()
127-
);
128-
129-
parsed
130-
}
131-
Err(e) => {
132-
// Don't treat this as fatal error
133-
panic!("failed to parse Godot version '{stdout}': {e}")
134-
}
135-
};
112+
let version = parse_godot_version(stdout)
113+
.unwrap_or_else(|err| panic!("failed to parse Godot version '{stdout}': {err}"));
114+
validate_godot_version(&version, stdout);
136115

137116
// `--dump-extension-api`, `--dump-gdextension-interface` etc. are only available in Debug builds (editor, debug export template).
138117
// If we try to run them in release builds, Godot tries to run, causing a popup alert with an unhelpful message:
@@ -191,53 +170,6 @@ fn dump_header_file(godot_bin: &Path, out_file: &Path) {
191170
println!("Generated {}/gdextension_interface.h.", cwd.display());
192171
}
193172

194-
fn patch_c_header(inout_h_path: &Path) {
195-
// The C header path *must* be passed in by the invoking crate, as the path cannot be relative to this crate.
196-
// Otherwise, it can be something like `/home/runner/.cargo/git/checkouts/gdext-76630c89719e160c/efd3b94/godot-bindings`.
197-
198-
println!("Patch C header '{}'...", inout_h_path.display());
199-
200-
let mut c = fs::read_to_string(inout_h_path)
201-
.unwrap_or_else(|_| panic!("failed to read C header file {}", inout_h_path.display()));
202-
203-
// Detect whether header is legacy (4.0) format. This should generally already be checked outside.
204-
assert!(
205-
c.contains("GDExtensionInterfaceGetProcAddress"),
206-
"C header file '{}' seems to be GDExtension version 4.0, which is no longer support by godot-rust.",
207-
inout_h_path.display()
208-
);
209-
210-
// Patch for variant converters and type constructors.
211-
c = c.replace(
212-
"typedef void (*GDExtensionVariantFromTypeConstructorFunc)(GDExtensionVariantPtr, GDExtensionTypePtr);",
213-
"typedef void (*GDExtensionVariantFromTypeConstructorFunc)(GDExtensionUninitializedVariantPtr, GDExtensionTypePtr);"
214-
)
215-
.replace(
216-
"typedef void (*GDExtensionTypeFromVariantConstructorFunc)(GDExtensionTypePtr, GDExtensionVariantPtr);",
217-
"typedef void (*GDExtensionTypeFromVariantConstructorFunc)(GDExtensionUninitializedTypePtr, GDExtensionVariantPtr);"
218-
)
219-
.replace(
220-
"typedef void (*GDExtensionPtrConstructor)(GDExtensionTypePtr p_base, const GDExtensionConstTypePtr *p_args);",
221-
"typedef void (*GDExtensionPtrConstructor)(GDExtensionUninitializedTypePtr p_base, const GDExtensionConstTypePtr *p_args);"
222-
);
223-
224-
// Use single regex with independent "const"/"Const", as there are definitions like this:
225-
// typedef const void *GDExtensionMethodBindPtr;
226-
let c = Regex::new(r"typedef (const )?void \*GDExtension(Const)?([a-zA-Z0-9]+?)Ptr;") //
227-
.expect("regex for mut typedef")
228-
.replace_all(&c, "typedef ${1}struct __Gdext$3 *GDExtension${2}${3}Ptr;");
229-
230-
// println!("Patched contents:\n\n{}\n\n", c.as_ref());
231-
232-
// Write the modified contents back to the file
233-
fs::write(inout_h_path, c.as_ref()).unwrap_or_else(|_| {
234-
panic!(
235-
"failed to write patched C header file {}",
236-
inout_h_path.display()
237-
)
238-
});
239-
}
240-
241173
pub(crate) fn locate_godot_binary() -> PathBuf {
242174
if let Ok(string) = std::env::var("GODOT4_BIN") {
243175
println!("Found GODOT4_BIN with path to executable: '{string}'");

godot-bindings/src/godot_json.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright (c) godot-rust; Bromeon and contributors.
3+
* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
*/
7+
8+
//! Commands related to parsing user-provided JSON and extension headers.
9+
10+
// At first re-using mapping from godot-codegen json.rs might seem more desirable but there are few issues to consider:
11+
// * Overall JSON file structure might change slightly from version to version, while header should stay consistent (otherwise it defeats the purpose of having any header at all).
12+
// Having two parsers – minimal one inherent to api-custom-json feature and codegen one – makes handling all the edge cases easier.
13+
// * `godot-codegen` depends on `godot-bindings` thus simple re-using types from former in side the latter is not possible (cyclic dependency).
14+
// Moving said types to `godot-bindings` would increase the cognitive overhead (since domain mapping is responsibility of `godot-codegen`, while godot-bindings is responsible for providing required resources & emitting the version).
15+
// In the future we might experiment with splitting said types into separate crates.
16+
17+
use crate::depend_on_custom_json::header_gen::{generate_rust_binding, patch_c_header};
18+
use crate::godot_version::validate_godot_version;
19+
use crate::{GodotVersion, StopWatch};
20+
use nanoserde::DeJson;
21+
use std::fs;
22+
use std::path::Path;
23+
24+
// GDExtension headers are backward compatible (new incremental changes in general are exposed as additions to the existing API) while godot-rust simply ignores extra declarations in header file.
25+
// Therefore, latest headers should work fine for all the past and future Godot versions – as long as the engine remains unchanged.
26+
// [version-sync] [[
27+
// [include] current.minor
28+
// [line] use gdextension_api::version_$snakeVersion::load_gdextension_header_h as load_latest_gdextension_headers;
29+
use gdextension_api::version_4_4::load_gdextension_header_h as load_latest_gdextension_headers;
30+
// ]]
31+
32+
/// A minimal version of deserialized JsonExtensionApi that includes only the header.
33+
#[derive(DeJson)]
34+
struct JsonExtensionApi {
35+
pub header: JsonHeader,
36+
}
37+
38+
/// Deserialized "header" key in given `extension_api.json`.
39+
#[derive(DeJson)]
40+
struct JsonHeader {
41+
pub version_major: u8,
42+
pub version_minor: u8,
43+
pub version_patch: u8,
44+
pub version_status: String,
45+
pub version_build: String,
46+
pub version_full_name: String,
47+
}
48+
49+
impl JsonHeader {
50+
fn into_godot_version(self) -> GodotVersion {
51+
GodotVersion {
52+
full_string: self.version_full_name,
53+
major: self.version_major,
54+
minor: self.version_minor,
55+
patch: self.version_patch,
56+
status: self.version_status,
57+
custom_rev: Some(self.version_build),
58+
}
59+
}
60+
}
61+
62+
pub fn load_custom_gdextension_json() -> String {
63+
let path = std::env::var("GODOT4_GDEXTENSION_JSON").expect(
64+
"godot-rust with `api-custom-json` feature requires GODOT4_GDEXTENSION_JSON \
65+
environment variable (with the path to the said json).",
66+
);
67+
let json_path = Path::new(&path);
68+
69+
fs::read_to_string(json_path).unwrap_or_else(|_| {
70+
panic!(
71+
"failed to open file with custom GDExtension JSON {}.",
72+
json_path.display()
73+
)
74+
})
75+
}
76+
77+
pub(crate) fn read_godot_version() -> GodotVersion {
78+
let extension_api: JsonExtensionApi = DeJson::deserialize_json(&load_custom_gdextension_json())
79+
.expect("failed to deserialize JSON");
80+
let version = extension_api.header.into_godot_version();
81+
82+
validate_godot_version(&version, &version.full_string);
83+
84+
version
85+
}
86+
87+
pub(crate) fn write_gdextension_headers(
88+
out_h_path: &Path,
89+
out_rs_path: &Path,
90+
watch: &mut StopWatch,
91+
) {
92+
// Allow to use custom gdextension headers in unlikely case if one uses heavily modified version of the engine.
93+
if let Ok(path) = std::env::var("GODOT4_GDEXTENSION_HEADERS") {
94+
let in_h_path = Path::new(&path);
95+
patch_c_header(in_h_path, out_h_path);
96+
watch.record("patch_header_h");
97+
} else {
98+
let h_contents = load_latest_gdextension_headers();
99+
fs::write(out_h_path, h_contents.as_ref())
100+
.unwrap_or_else(|e| panic!("failed to write gdextension_interface.h: {e}"));
101+
watch.record("write_header_h");
102+
}
103+
104+
generate_rust_binding(out_h_path, out_rs_path);
105+
watch.record("generate_header_rs");
106+
}

godot-bindings/src/godot_version.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
66
*/
77

8-
//#![allow(unused_variables, dead_code)]
8+
// This file is explicitly included in unit tests
9+
// while all the functions included are used only with `custom-api` and `custom-api-json` features.
10+
#![allow(unused_variables, dead_code)]
911

1012
use crate::GodotVersion;
1113
use regex::{Captures, Regex};
@@ -52,6 +54,21 @@ pub fn parse_godot_version(version_str: &str) -> Result<GodotVersion, Box<dyn Er
5254
})
5355
}
5456

57+
pub(crate) fn validate_godot_version(godot_version: &GodotVersion, version_str: &str) {
58+
assert_eq!(
59+
godot_version.major,
60+
4,
61+
"Only Godot versions with major version 4 are supported; found version {}.",
62+
version_str.trim()
63+
);
64+
65+
assert!(
66+
godot_version.minor > 0,
67+
"Godot 4.0 is no longer supported by godot-rust; found version {}.",
68+
version_str.trim()
69+
);
70+
}
71+
5572
/// Extracts and parses a named capture group from a regex match.
5673
fn cap<T: FromStr>(caps: &Captures, key: &str) -> Result<Option<T>, Box<dyn Error>> {
5774
caps.name(key)

0 commit comments

Comments
 (0)