# 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**](https://github.com/VioletHelianthus/uika) by [**VioletHelianthus**](https://github.com/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 `UClass`es, 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`](https://github.com/viniciusmorgado/rusteal/tree/main/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
```bash
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
```bash
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](#contributing).
### An existing project
```bash
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:
```toml
[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`:**
```toml
[lib]
crate-type = ["cdylib"]
[dependencies]
rusteal-runtime = { workspace = true }
bindings = { path = "../bindings" }
glam = { workspace = true }
```
**`Rust/your-game/src/lib.rs`:**
```rust
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:
| 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:
```bash
# 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
```rust
// 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
```rust
#[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:
```rust
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:
```bash
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
| 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:
```bash
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:
| `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
| `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`](https://github.com/viniciusmorgado/rusteal/blob/main/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:
| `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](#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:
- Apache License, Version 2.0 ([LICENSE-APACHE](https://github.com/viniciusmorgado/rusteal/blob/main/LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](https://github.com/viniciusmorgado/rusteal/blob/main/LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
at your option.