rusteal 0.3.0

CLI tool for rusteal: codegen, project setup, and build orchestration
rusteal-0.3.0 is not a library.

Rusteal

Rust bindings for Unreal Engine 5.8

Rusteal lets you write Unreal Engine gameplay in Rust. Your Rust code compiles to a shared library that is loaded by a small UE C++ plugin. All UE API calls cross the FFI boundary through a function pointer table — no C++ compilation required during Rust iteration.

⚠️ Early Stage Project — Rusteal is under active development and not ready for production use. APIs will change without notice, documentation is incomplete, and many UE features are not yet covered. Contributions and feedback are welcome, but please do not use this for shipping projects.

Acknowledgments

Rusteal is a hard fork of uika by VioletHelianthus, who designed and wrote the foundation this project stands on: the reflection-driven code generator, the UHT exporter plugin, the reification of Rust structs as UClasses, and the runtime. Thank you.

Rusteal does not intend to stay compatible with the original uika, unless its author wants to port the improvements back. From here on it develops the Unreal bindings independently.

Example

examples/gem_collector in this repository is a full working demo of the runtime API: copy the directory into a project's Rust/ and add it to the workspace members.

Getting Started

Prerequisites

  • Unreal Engine 5.8 (source or installed build)
  • Rust (stable, latest recommended)
  • Linux: the clang toolchain and .NET runtime bundled with the engine
  • Windows: Visual Studio 2022 with the C++ workload

Install

cargo install rusteal

That installs the rusteal binary; the engine plugins and the project templates travel inside it. The first command that needs the engine asks where it is and keeps the answer in ~/.config/rusteal/config.toml — the only per-machine setting Rusteal has.

A new project

rusteal new MyGame --dir ~/Projects

rusteal new creates a UE project from the engine's Blank C++ template, installs the Rusteal plugins, writes the Rust workspace and runs the whole build pipeline. Then open MyGame.uproject, drop a HelloActor into the level and press Play: the Output Log shows [MyGame] Hello from Rust.

It does not run git init — versioning is your call — but it does write a .gitignore covering the Unreal and Rust build output, so a later git init picks up the right files.

Two flags: --no-build stops after writing the project, leaving the pipeline for a later rusteal build; --runtime-path makes the project depend on a Rusteal checkout instead of the published crates, which is for working on Rusteal itself — see Contributing.

An existing project

rusteal setup /path/to/YourProject   # plugins + starter rusteal.toml
rusteal build                        # from anywhere inside the project

setup does not write the Rust workspace; create it as below, or copy the layout rusteal new produces.

Versions

A project is tied to one Rusteal version: Rust/Cargo.toml pins rusteal-runtime, rusteal-core and rusteal-ffi to it ("=x.y.z"), and the plugins in Plugins/ carry it. Before doing anything, build, generate and setup check it against the CLI's own version:

  • the CLI is newer: rusteal upgrade moves the project to it — the pins, the plugins (Plugins/Rusteal and Plugins/RustealGenerator are replaced wholesale) and a full build;
  • the project is newer: install the CLI it uses, cargo install rusteal@x.y.z.

The plugin checks the library as well: a librusteal built against another version is refused at load, with both versions in the Output Log.

Project layout

Everything lives in one repository; the only thing outside it is the engine path in the CLI's own configuration.

YourProject/
├── YourProject.uproject
├── rusteal.toml                # project configuration (versioned)
├── Source/YourProject/         # the C++ module UBT needs
├── Plugins/Rusteal/            # runtime plugin + generated C++ wrappers
├── Plugins/RustealGenerator/   # UHT exporter
├── Intermediate/Rusteal/uht/   # reflection JSON (build output)
└── Rust/
    ├── Cargo.toml              # workspace
    ├── your-game/              # your code (cdylib)
    └── bindings/               # generated by `rusteal build`, committed

Rust/Cargo.toml — rusteal new pins the version of the CLI that created the project:

[workspace]
members = ["your-game", "bindings"]
resolver = "3"

[workspace.dependencies]
rusteal-runtime = "=x.y.z"
rusteal-core = "=x.y.z"
rusteal-ffi = "=x.y.z"
glam = "0.33"

Rust/your-game/Cargo.toml:

[lib]
crate-type = ["cdylib"]

[dependencies]
rusteal-runtime = { workspace = true }
bindings = { path = "../bindings" }
glam = { workspace = true }

Rust/your-game/src/lib.rs:

rusteal_runtime::entry!();

mod my_game;

The runtime types come from rusteal_runtime::prelude, the engine types from the generated crate's own bindings::prelude.

Build Pipeline

The CLI orchestrates a 5-step build:

Step Command What it does
1 UE Build Compiles UE project, triggers RustealGenerator → JSON reflection data
2 Codegen Reads JSON → generates the bindings crate + C++ wrappers
3 UE Rebuild Compiles the generated C++ wrappers into the UE module
4 Cargo Build cargo build --release on your cdylib crate
5 Deploy Copies the library to Plugins/Rusteal/Binaries/<Platform>/ (rusteal.dll, librusteal.so)

Commands take the project directory, or find it by walking up from the current one:

# Full build
rusteal build

# Rust-only rebuild (skip the UE steps)
rusteal build --from 4

# Codegen and everything after
rusteal build --from 2

# Just regenerate the bindings crate and the C++ wrappers
rusteal generate /path/to/YourProject

Key Concepts

Object References

// Lightweight handle (8 bytes, Copy). May become invalid if UE GCs the object.
let actor: UObjectRef<Actor> = world.spawn_actor(&transform)?;

// RAII GC root. Prevents garbage collection until dropped.
let pinned: Pinned<Actor> = actor.pin()?;

// Checked access — verifies the object is still alive before use.
let checked = actor.checked()?;
checked.k2_get_actor_location();

Defining UE Classes

#[uclass(parent = Actor)]
pub struct MyActor {
    #[component(root)]
    root: SceneComponent,

    #[component(attach = "root")]
    mesh: StaticMeshComponent,

    #[uproperty(BlueprintReadWrite, default = 100)]
    health: i32,

    // Rust-only field (not exposed to UE)
    internal_state: Vec<String>,
}

#[uclass_impl]
impl MyActor {
    #[ufunction(Override)]
    fn receive_begin_play(&mut self) { /* ... */ }

    #[ufunction(BlueprintCallable)]
    fn take_damage(&mut self, amount: i32) {
        self.set_health(self.health() - amount);
    }
}

Dynamic Calls

For Blueprint-defined functions or APIs not covered by generated bindings:

let mut call = DynamicCall::new(&actor, "SetMobility")?;
call.set("NewMobility", 2u8)?;
call.call()?;

Hot Reload

During development, rebuild your Rust library and reload without restarting the editor:

rusteal build --from 4

Then in the UE console:

Rusteal.Reload

Function implementations update immediately. Adding/removing uproperty or ufunction requires an editor restart.

Platform Support

Platform Status
Linux (x64) Supported (Unreal Engine 5.8.2)
Windows (x64) Supported
macOS Not yet tested

Contributing

Working on Rusteal itself, rather than on a game:

git clone https://github.com/viniciusmorgado/rusteal.git
cd rusteal

# The workspace builds, tests and lints without the engine
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --no-deps

# A throwaway project that depends on this checkout
cargo run -p rusteal -- new Probe --dir /tmp --runtime-path .

# After changing the runtime, the macros or a game crate
cargo run -p rusteal -- build /tmp/Probe --from 4

In the editor, Rusteal.Reload swaps the library in without restarting; adding or removing a uproperty/ufunction still needs a restart.

A project made with --runtime-path follows that checkout: the CLI acts on it only when built from the same checkout, so drive it with cargo run -p rusteal -- from there. After pulling, cargo run -p rusteal -- setup /tmp/Probe reinstalls the plugins at the checkout's version.

--runtime-path

It decides where the generated project takes Rusteal from:

without it --runtime-path <checkout>
Rust/Cargo.toml says rusteal-runtime = "=x.y.z" { path = "<checkout>/rusteal-runtime" }
Crates come from crates.io the local checkout
A change in the runtime reaches the game on the next published version on the next build
The project builds on another machine yes no (the path is this machine's)

So: without it for a real game, with it while working on Rusteal — a change in rusteal-core shows up in the probe project on the next build --from 4, with nothing to publish in between.

What lives where

Crate Role
rusteal-ue-flags UE reflection flag constants
rusteal-ffi #[repr(C)] types and the API table: the Rust ↔ C++ contract
rusteal-core the safe runtime: object references, lifecycle, containers, math
rusteal-macros #[uclass], #[uclass_impl] and their attributes
rusteal-runtime what a game depends on; re-exports the above
rusteal-codegen reflection JSON → the bindings crate and the C++ wrappers; owns manual/
rusteal-cli the rusteal binary; owns templates/ and embeds ue_plugin/

Changing the C++ plugin means running cargo run -p rusteal -- sync-plugin before publishing, which refreshes the snapshot the binary embeds.

Engine APIs Rusteal uses that Unreal has deprecated are tracked in docs/ue-deprecations.md: what, since which UE version, until when and where. A new engine version means checking its warnings against that list.

Commits are small — one per fix — and never mention AI authorship.

Releases and versions

Everything Rusteal ships carries one version: the seven crates (they inherit [workspace.package].version through version.workspace = true), the two UE plugins and the FFI contract between them.

Where it comes from. The release is cut from a develop → main PR. The prepare-release workflow takes the last released version from crates.io (max_version of the rusteal crate, the one source that cannot drift) and bumps it by the commits the PR adds: BREAKING CHANGE: → major, feat: → minor, anything else → patch. It never reads the version from the repository's files. If nothing that goes into a package changed since the last tag, it skips the release.

Where it is written. The same bump commit writes it everywhere at once:

File Field 0.2.1 becomes
Cargo.toml [workspace.package].version and the internal { version, path } dependencies 0.2.1
ue_plugin/*/*.uplugin VersionName "0.2.1"
ue_plugin/*/*.uplugin Version 2001

Tags (v0.2.1), crates.io and the manifests only ever use major.minor.patch.

The integer form. Two places cannot hold 0.2.1: the .uplugin Version field, which UE requires to be an integer, and RustealApiTable::version, a u32. Both use the same encoding, major * 1_000_000 + minor * 1_000 + patch (rusteal_ffi::encode_version). It is always derived from the version, never a source of it. Minor and patch must stay below 1000, or the number would be ambiguous; encode_version refuses such a version at compile time, so it cannot slip through.

How it is kept in line.

  • rusteal-cli/tests/plugin_version.rs fails if either .uplugin disagrees with the workspace version, so a descriptor edited by hand turns CI red.
  • At load, the plugin compares its own version with the library's rusteal_version() export and refuses a library built against another one; rusteal_runtime::init checks the table's version again.
  • Before build, generate and setup, the CLI compares the project's pins and plugins with its own version (see Versions).

Cargo.lock is not versioned: every build would rewrite the internal versions in it. A dependency release that breaks the workspace shows up in CI, where it gets fixed. cargo install rusteal --locked still works, because cargo packs a lock file of its own into the published crate.

License

Licensed under either of:

at your option.