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