Skip to main content

rusteal_codegen/
lib.rs

1// rusteal-codegen: reads UHT JSON, generates Rust bindings + C++ wrappers + FuncId tables.
2
3pub mod schema;
4pub mod naming;
5pub mod config;
6pub mod context;
7pub mod type_map;
8pub mod defaults;
9pub mod filter;
10pub mod rust_gen;
11pub mod cpp_gen;
12
13use std::path::Path;
14
15use crate::config::{
16    CodegenConfig, LibraryKind, LibraryOutput, PluginConfig, PluginLayout, ProjectConfig,
17    ProjectLayout,
18};
19use crate::schema::{ClassesFile, EnumsFile, StructsFile};
20
21/// Write `contents` to `path` unless it already holds exactly that: a
22/// regenerated file that did not change keeps its timestamp, so UBT and Cargo
23/// do not rebuild it.
24pub(crate) fn write_if_changed(path: &Path, contents: &str) -> std::io::Result<()> {
25    if std::fs::read(path).is_ok_and(|old| old == contents.as_bytes()) {
26        return Ok(());
27    }
28    std::fs::write(path, contents)
29}
30
31/// Generate the game's library: the bindings crate in `Rust/bindings` and
32/// the C++ wrappers compiled into the Rusteal plugin.
33///
34/// `project_root` is the directory holding the .uproject and `rusteal.toml`.
35pub fn run_generate(project_root: &Path) {
36    let config = ProjectConfig::load(project_root).unwrap_or_else(|e| {
37        eprintln!("rusteal-codegen: {e}");
38        std::process::exit(1);
39    });
40    let layout = ProjectLayout::new(project_root);
41    generate_library(&layout.uht_json(), &config.codegen, &layout.game_output());
42}
43
44/// Generate a Rusteal plugin's library: the bindings crate in the plugin's
45/// `Rust/bindings` and the C++ wrappers compiled into the plugin's module,
46/// from the project's reflection JSON.
47pub fn run_generate_plugin(project_root: &Path, plugin: &PluginLayout) {
48    let config = PluginConfig::load(&plugin.dir).unwrap_or_else(|e| {
49        eprintln!("rusteal-codegen: {e}");
50        std::process::exit(1);
51    });
52    let layout = ProjectLayout::new(project_root);
53    eprintln!("rusteal-codegen: plugin {}", plugin.name);
54    generate_library(&layout.uht_json(), &config.codegen, &plugin.output(config.plugin.kind));
55}
56
57/// Generate one library's bindings crate and C++ wrappers from the
58/// reflection JSON in `uht_input`.
59pub fn generate_library(uht_input: &Path, codegen: &CodegenConfig, output: &LibraryOutput) {
60    let codegen = &codegen.for_library(output.kind);
61    let classes_path = uht_input.join("rusteal_classes.json");
62    let structs_path = uht_input.join("rusteal_structs.json");
63    let enums_path = uht_input.join("rusteal_enums.json");
64    // The generated crate's directory; its sources go under `src/`.
65    let rust_out = output.bindings_crate.clone();
66    let rust_src = rust_out.join("src");
67    let cpp_out = output.cpp_generated.clone();
68
69    eprintln!("rusteal-codegen: loading JSON...");
70
71    // Parse JSON files
72    let classes_json: ClassesFile = {
73        let data = std::fs::read_to_string(&classes_path)
74            .unwrap_or_else(|e| panic!("Failed to read {}: {e}", classes_path.display()));
75        serde_json::from_str(&data)
76            .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", classes_path.display()))
77    };
78
79    let structs_json: StructsFile = {
80        let data = std::fs::read_to_string(&structs_path)
81            .unwrap_or_else(|e| panic!("Failed to read {}: {e}", structs_path.display()));
82        serde_json::from_str(&data)
83            .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", structs_path.display()))
84    };
85
86    let enums_json: EnumsFile = {
87        let data = std::fs::read_to_string(&enums_path)
88            .unwrap_or_else(|e| panic!("Failed to read {}: {e}", enums_path.display()));
89        serde_json::from_str(&data)
90            .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", enums_path.display()))
91    };
92
93    eprintln!(
94        "  Loaded {} classes, {} structs, {} enums",
95        classes_json.classes.len(),
96        structs_json.structs.len(),
97        enums_json.enums.len()
98    );
99
100    // Build context
101    let mut ctx = context::CodegenContext::new(
102        classes_json.classes,
103        structs_json.structs,
104        enums_json.enums,
105        codegen,
106    );
107    ctx.cpp_prefix = output.cpp_prefix.clone();
108
109    eprintln!(
110        "  Enabled modules: {:?}",
111        ctx.enabled_modules.iter().collect::<Vec<_>>()
112    );
113    for (module, classes) in &ctx.module_classes {
114        eprintln!("    {}: {} classes", module, classes.len());
115    }
116    for (module, structs) in &ctx.module_structs {
117        eprintln!("    {}: {} structs", module, structs.len());
118    }
119    for (module, enums) in &ctx.module_enums {
120        eprintln!("    {}: {} enums", module, enums.len());
121    }
122
123    // Apply filters
124    eprintln!("rusteal-codegen: filtering...");
125    let blocklist = with_unlisted_classes(&ctx, codegen);
126    filter::apply_filters(&mut ctx, &blocklist, output.kind == LibraryKind::Editor);
127
128    // Build function table (assign FuncIds)
129    eprintln!("rusteal-codegen: building function table...");
130    build_func_table(&mut ctx);
131    eprintln!("  {} functions in func_table", ctx.func_table.len());
132
133    // Generate the Rust bindings crate: sources, extensions, prelude, manifest.
134    eprintln!("rusteal-codegen: generating Rust code...");
135    rust_gen::generate(&ctx, &rust_src);
136    rust_gen::manual::write_manual_module(&rust_src);
137    rust_gen::prelude::write_prelude(&rust_src, &ctx);
138    rust_gen::cargo_toml::write_crate_files(&rust_out, &ctx, codegen);
139
140    // Generate C++ code
141    eprintln!("rusteal-codegen: generating C++ code...");
142    cpp_gen::generate(&ctx, &cpp_out);
143
144    // Generate module_deps.txt for Rusteal.Build.cs, and list the plugins
145    // those modules come from in the host plugin's descriptor
146    generate_module_deps(codegen, &output.host_module, &cpp_out);
147    update_plugin_dependencies(codegen, &output.plugin_descriptor);
148
149    // Post-generate verification
150    eprintln!("rusteal-codegen: verifying output...");
151    verify_output(&ctx, &rust_src, &cpp_out);
152
153    eprintln!("rusteal-codegen: done!");
154}
155
156/// The blocklist plus, for each module that lists the classes it wants
157/// (`classes = [...]` in `[codegen.modules]`), every other class of it, but
158/// the ones the listed classes derive from.
159fn with_unlisted_classes(ctx: &context::CodegenContext, codegen: &CodegenConfig) -> config::Blocklist {
160    use std::collections::HashSet;
161
162    let mut blocklist = codegen.blocklist.clone();
163    for (package, mapping) in &codegen.modules {
164        let Some(listed) = &mapping.classes else {
165            continue;
166        };
167        let mut wanted: HashSet<&str> = HashSet::new();
168        for name in listed {
169            let mut current = Some(name.as_str());
170            while let Some(class) = current.and_then(|n| ctx.classes.get(n)) {
171                if &class.package != package || !wanted.insert(class.name.as_str()) {
172                    break;
173                }
174                current = class.super_class.as_deref();
175            }
176        }
177        for class in ctx.module_classes.get(&mapping.module).into_iter().flatten() {
178            if !wanted.contains(class.name.as_str()) {
179                blocklist.classes.push(class.name.clone());
180            }
181        }
182    }
183    blocklist
184}
185
186/// Generate module_deps.txt listing UE module names needed by enabled features.
187/// The module the wrappers compile into is never its own dependency.
188fn generate_module_deps(config: &CodegenConfig, host_module: &str, cpp_out: &Path) {
189    use std::collections::BTreeSet;
190
191    let enabled_features: std::collections::HashSet<&str> =
192        config.features.iter().map(|s| s.as_str()).collect();
193
194    // Collect UE package names whose feature is enabled.
195    // "Core" is always needed (UE base) and not in the modules map.
196    let mut ue_modules: BTreeSet<&str> = BTreeSet::new();
197    ue_modules.insert("Core");
198    for (pkg, mapping) in &config.modules {
199        if enabled_features.contains(mapping.feature.as_str()) {
200            ue_modules.insert(pkg.as_str());
201        }
202    }
203    ue_modules.remove(host_module);
204
205    let content = ue_modules.iter().copied().collect::<Vec<_>>().join("\n");
206
207    let path = cpp_out.join("module_deps.txt");
208    write_if_changed(&path, &content)
209        .unwrap_or_else(|e| panic!("Failed to write {}: {e}", path.display()));
210
211    eprintln!("  module_deps.txt: {:?}", ue_modules.iter().collect::<Vec<_>>());
212}
213
214/// Add the engine plugins of the enabled modules (`plugin = "StateTree"` in
215/// `[codegen.modules]`) to the Rusteal plugin's descriptor: UBT wants a plugin
216/// to list the plugins whose modules it links. Plugins already listed stay.
217fn update_plugin_dependencies(config: &CodegenConfig, descriptor: &Path) {
218    let Ok(text) = std::fs::read_to_string(descriptor) else {
219        return; // no installed plugin (a codegen test): nothing to update
220    };
221    let mut json: serde_json::Value = serde_json::from_str(&text)
222        .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", descriptor.display()));
223    let enabled: std::collections::HashSet<&str> =
224        config.features.iter().map(|s| s.as_str()).collect();
225    let mut wanted: Vec<&str> = config
226        .modules
227        .values()
228        .filter(|m| enabled.contains(m.feature.as_str()))
229        .filter_map(|m| m.plugin.as_deref())
230        .collect();
231    wanted.sort();
232    wanted.dedup();
233
234    let plugins = json
235        .as_object_mut()
236        .expect("a plugin descriptor is a JSON object")
237        .entry("Plugins")
238        .or_insert_with(|| serde_json::Value::Array(Vec::new()))
239        .as_array_mut()
240        .expect("a plugin descriptor's Plugins is an array");
241    let mut added = Vec::new();
242    for name in wanted {
243        if plugins.iter().any(|p| p["Name"] == name) {
244            continue;
245        }
246        plugins.push(serde_json::json!({ "Name": name, "Enabled": true }));
247        added.push(name);
248    }
249    if added.is_empty() {
250        return;
251    }
252    let mut out = serde_json::to_string_pretty(&json).expect("serializable descriptor");
253    out.push('\n');
254    std::fs::write(descriptor, out)
255        .unwrap_or_else(|e| panic!("Failed to write {}: {e}", descriptor.display()));
256    let name = descriptor.file_name().unwrap_or_default().to_string_lossy();
257    eprintln!("  {name}: now depends on {added:?}");
258}
259
260/// Verify codegen output integrity.
261fn verify_output(ctx: &context::CodegenContext, rust_out: &Path, cpp_out: &Path) {
262    let mut errors: Vec<String> = Vec::new();
263
264    // 1. FuncId contiguity: IDs must be 0..N-1 with no gaps
265    for (i, entry) in ctx.func_table.iter().enumerate() {
266        if entry.func_id != i as u32 {
267            errors.push(format!(
268                "FuncId gap: expected {} for module '{}' {}.{}, got {} (total functions: {})",
269                i, entry.module_name, entry.class_name, entry.func_name,
270                entry.func_id, ctx.func_table.len()
271            ));
272            break; // one error is enough to flag the issue
273        }
274    }
275
276    // 2. Required Rust output files exist and are non-empty
277    let rust_required = ["lib.rs", "func_ids.rs"];
278    for name in &rust_required {
279        let path = rust_out.join(name);
280        match std::fs::metadata(&path) {
281            Ok(m) if m.len() == 0 => errors.push(format!("Rust output empty: {}", path.display())),
282            Err(_) => errors.push(format!("Rust output missing: {}", path.display())),
283            _ => {}
284        }
285    }
286
287    // 3. Per-module mod.rs exists for each enabled module
288    for module_name in ctx.enabled_modules.iter() {
289        let mod_path = rust_out.join(module_name).join("mod.rs");
290        if !mod_path.exists() {
291            errors.push(format!("Module mod.rs missing: {}", mod_path.display()));
292        }
293    }
294
295    // 4. Required C++ output files exist and are non-empty
296    let cpp_required = ["RustealFuncIds.h", "RustealFillFuncTable.cpp"];
297    for name in &cpp_required {
298        let path = cpp_out.join(name);
299        match std::fs::metadata(&path) {
300            Ok(m) if m.len() == 0 => errors.push(format!("C++ output empty: {}", path.display())),
301            Err(_) => errors.push(format!("C++ output missing: {}", path.display())),
302            _ => {}
303        }
304    }
305
306    // 5. Summary stats
307    let func_count = ctx.func_table.len();
308    let module_count = ctx.enabled_modules.len();
309    let class_count: usize = ctx.module_classes.values().map(|v| v.len()).sum();
310    let struct_count: usize = ctx.module_structs.values().map(|v| v.len()).sum();
311    let enum_count: usize = ctx.module_enums.values().map(|v| v.len()).sum();
312
313    if errors.is_empty() {
314        eprintln!(
315            "  OK: {} modules, {} classes, {} structs, {} enums, {} functions",
316            module_count, class_count, struct_count, enum_count, func_count
317        );
318    } else {
319        eprintln!("  Verification FAILED:");
320        for e in &errors {
321            eprintln!("    - {e}");
322        }
323        std::process::exit(1);
324    }
325}
326
327/// Assign deterministic FuncIds to all exportable functions.
328fn build_func_table(ctx: &mut context::CodegenContext) {
329    let mut entries = Vec::new();
330
331    for (module_name, classes) in &ctx.module_classes {
332        for class in classes {
333            // Skip UInterface classes — their functions can't be called directly
334            if class.super_class.as_deref() == Some("Interface") {
335                continue;
336            }
337            for func in &class.funcs {
338                // Skip functions with unsupported param types
339                let all_supported = func.params.iter().all(|p| {
340                    type_map::map_property_type(
341                        &p.prop_type,
342                        p.class_name.as_deref(),
343                        p.struct_name.as_deref(),
344                        p.enum_name.as_deref(),
345                        p.enum_underlying_type.as_deref(),
346                        p.meta_class_name.as_deref(),
347                        p.interface_name.as_deref(),
348                    )
349                    .supported
350                });
351                if !all_supported {
352                    continue;
353                }
354                entries.push(context::FuncEntry {
355                    func_id: 0, // assigned below
356                    module_name: module_name.clone(),
357                    class_name: class.name.clone(),
358                    func_name: func.name.clone(),
359                    rust_func_name: naming::to_snake_case(&func.name),
360                    func: func.clone(),
361                    cpp_class_name: class.cpp_name.clone(),
362                    header: class.header.clone(),
363                });
364            }
365        }
366    }
367
368    // Sort by (module, class, func) for deterministic IDs
369    entries.sort_by(|a, b| {
370        a.module_name
371            .cmp(&b.module_name)
372            .then_with(|| a.class_name.cmp(&b.class_name))
373            .then_with(|| a.func_name.cmp(&b.func_name))
374    });
375
376    // Assign sequential IDs
377    for (i, entry) in entries.iter_mut().enumerate() {
378        entry.func_id = i as u32;
379    }
380
381    ctx.func_table = entries;
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn listed_classes_keep_their_ancestors_only() {
390        let class = |name: &str, package: &str, parent: &str| -> schema::ClassInfo {
391            serde_json::from_value(serde_json::json!({
392                "name": name, "cpp_name": format!("U{name}"), "package": package,
393                "header": "", "class_flags": 0, "super": parent,
394            }))
395            .unwrap()
396        };
397        let codegen: CodegenConfig = toml::from_str(
398            r#"
399            features = ["core", "editor"]
400            [modules]
401            CoreUObject = { module = "core_ue", feature = "core" }
402            UnrealEd = { module = "unreal_ed", feature = "editor", classes = ["EditorActorSubsystem"] }
403            "#,
404        )
405        .unwrap();
406        let ctx = context::CodegenContext::new(
407            vec![
408                class("Object", "CoreUObject", ""),
409                class("EditorSubsystemBase", "UnrealEd", "Object"),
410                class("EditorActorSubsystem", "UnrealEd", "EditorSubsystemBase"),
411                class("Factory", "UnrealEd", "Object"),
412            ],
413            vec![],
414            vec![],
415            &codegen,
416        );
417        let blocked = with_unlisted_classes(&ctx, &codegen).classes;
418        assert_eq!(blocked, ["Factory"]);
419    }
420
421    #[test]
422    fn unchanged_output_keeps_its_timestamp() {
423        let dir = std::env::temp_dir().join(format!("rusteal-write-{}", std::process::id()));
424        std::fs::create_dir_all(&dir).unwrap();
425        let path = dir.join("out.rs");
426        write_if_changed(&path, "a").unwrap();
427        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
428        std::fs::File::options().write(true).open(&path).unwrap().set_modified(old).unwrap();
429
430        write_if_changed(&path, "a").unwrap();
431        assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), old);
432        write_if_changed(&path, "b").unwrap();
433        assert_ne!(std::fs::metadata(&path).unwrap().modified().unwrap(), old);
434        assert_eq!(std::fs::read_to_string(&path).unwrap(), "b");
435        std::fs::remove_dir_all(&dir).unwrap();
436    }
437
438    #[test]
439    fn plugin_dependencies_follow_enabled_modules() {
440        let config: crate::config::CodegenConfig = toml::from_str(
441            r#"
442            features = ["engine", "state-tree"]
443            [modules]
444            Engine = { module = "engine", feature = "engine" }
445            StateTreeModule = { module = "state_tree", feature = "state-tree", plugin = "StateTree" }
446            GameplayStateTreeModule = { module = "gameplay_state_tree", feature = "state-tree", plugin = "GameplayStateTree" }
447            Niagara = { module = "niagara", feature = "niagara", plugin = "Niagara" }
448            "#,
449        )
450        .unwrap();
451        let dir = std::env::temp_dir().join(format!("rusteal-uplugin-{}", std::process::id()));
452        std::fs::create_dir_all(&dir).unwrap();
453        let descriptor = dir.join("Rusteal.uplugin");
454        std::fs::write(
455            &descriptor,
456            r#"{ "VersionName": "0.0.0", "Plugins": [ { "Name": "EnhancedInput", "Enabled": true } ] }"#,
457        )
458        .unwrap();
459
460        update_plugin_dependencies(&config, &descriptor);
461        update_plugin_dependencies(&config, &descriptor);
462
463        let json: serde_json::Value =
464            serde_json::from_str(&std::fs::read_to_string(&descriptor).unwrap()).unwrap();
465        let names: Vec<&str> = json["Plugins"]
466            .as_array()
467            .unwrap()
468            .iter()
469            .map(|p| p["Name"].as_str().unwrap())
470            .collect();
471        assert_eq!(names, ["EnhancedInput", "GameplayStateTree", "StateTree"]);
472        assert_eq!(json["VersionName"], "0.0.0");
473        std::fs::remove_dir_all(&dir).unwrap();
474    }
475}