Introduction
Rusteal integrates the Rust programming language into Unreal Engine, so you can write both gameplay code and plugins in Rust.
How it Works
Rusteal lets Rust code take part in Unreal Engine as a first-class language: it calls the engine’s API, and it defines classes the engine, the editor and Blueprints treat as their own. This chapter explains how. The design comes from Uika, and its core idea is simple: Rusteal does not describe the engine by hand, it reads the engine’s own reflection.
The engine already describes itself
Every class, struct, enum, property and function that Unreal exposes to Blueprints is declared with macros such as UCLASS, USTRUCT, UPROPERTY and UFUNCTION. During the build, the Unreal Header Tool (UHT) reads those declarations and produces the reflection data that the editor, Blueprints, serialization and networking all rely on.
That reflection data is a complete, machine-readable description of the engine’s scripting API. Rusteal uses it as its single source of truth: whatever Blueprints can reach, Rust can reach, for the exact engine version installed and the engine plugins the project enables (Enhanced Input, StateTree, Niagara, Gameplay Abilities, and so on).
From reflection to bindings
Rusteal turns that description into Rust code in three stages.
Unreal headers ──UHT──▶ RustealExporter ──▶ reflection JSON
│
rusteal-codegen
┌────────┴────────┐
▼ ▼
Rust bindings crate C++ wrappers
(typed Rust API) (one per engine function)
- Export.
RustealExporteris a plugin for the UHT itself, written in C#. It runs inside the engine’s build and writes every reflected type to JSON: names, flags, properties, function signatures, inheritance and the header each type lives in. - Codegen.
rusteal-codegenreads that JSON and generates two things:- a Rust bindings crate, with a Rust type for every engine class, struct and enum, and a typed method for every function;
- a set of C++ wrappers: one small
extern "C"function per engine function, which calls the real C++ function natively.
- Compile. The C++ wrappers are compiled into the Rusteal plugin by the Unreal Build Tool, and the game’s crate is compiled by Cargo against the bindings.
Because everything is generated, the bindings follow the engine: a new engine version or a newly enabled engine plugin is a regeneration away, not a rewrite.
Crossing the boundary
The Rust code is compiled into a shared library (librusteal.so, rusteal.dll) that the Rusteal plugin loads at startup. The two sides talk through a single, versioned contract defined in rusteal-ffi: plain #[repr(C)] types and tables of function pointers.
- The API table holds the plugin’s own services: object lifetime, properties, delegates, containers, input, worlds, and the reification of Rust classes. The plugin hands it to the library when it loads it.
- The function table holds a pointer to every generated C++ wrapper. Calling
actor.k2_get_actor_location()in Rust looks up the wrapper in that table and calls it; the wrapper casts the object and callsAActor::K2_GetActorLocationdirectly, with no interpreter and noProcessEventin between. - Properties are reached through reflection: the property is found once by name, and its offset is reused afterwards.
Engine errors become Rust values: a call on an object the garbage collector has destroyed returns an error instead of crashing, and Rust code handles it with Result, like any other failure.
Since every call goes through these tables, iterating on Rust code never requires compiling C++: only Cargo runs.
Rust classes the engine sees
Calling the engine is half of the story. The other half is the engine calling Rust.
A Rust struct marked with #[uclass] becomes a real UClass. When the library loads, Rusteal reifies it: it creates the class at runtime, with its parent (Actor, Character, GameModeBase, a subsystem…), its #[uproperty] fields as real properties, its #[component]s as default subobjects, and its #[ufunction]s as real functions whose native entry point calls back into Rust.
To the engine, that class is indistinguishable from one declared in C++ or in a Blueprint:
- it appears in the editor and can be placed in a level;
- a Blueprint can derive from it and set its properties as class defaults;
- its functions can be called from Blueprints, overridden as events (
ReceiveBeginPlay,ReceiveTick…), or bound to delegates and input actions; - it can be saved, configured from ini files, and cooked into a packaged game.
The Rust data of each object lives on the Rust side, next to the engine object, and is created and dropped with it.
Hot reload
Because the engine only knows the library through its tables, the library can be swapped while the editor runs. Rusteal.Reload unloads it, loads the new build, and points every reified function at the new code: a change to a function body is live in seconds, without restarting the editor or touching C++.
Games and plugins
The same machinery builds Unreal Engine plugins as well as games. A project has one Rust library for the game and one more for each Rusteal plugin, and the Rusteal plugin hosts all of them side by side:
Plugins/Rusteal/Binaries/<Platform>/librusteal.so → /Script/Rusteal
Plugins/Inventory/Binaries/<Platform>/librusteal_Inventory.so → /Script/Inventory
Plugins/LevelTools/Binaries/<Platform>/librusteal_LevelTools.so → /Script/LevelTools
Each library has its own bindings crate, its own C++ wrappers and its own tables, and its classes live in its own package, exactly as a C++ plugin’s do. When the engine calls into Rust, the Rusteal plugin first selects the library that owns the class, so each library sees only its own objects and can be rebuilt and reloaded on its own.
A plugin is one of two kinds:
- a runtime plugin ships with the game: gameplay systems such as an inventory, built on actors, components and subsystems;
- an editor plugin only runs in the editor: tools built on editor subsystems, menus, asset validators and the editor scripting API, with bindings to the editor’s own modules.
A plugin carries its Rust code, bindings and binaries in its own directory, so it can be copied into another Rusteal project or kept in a repository of its own.
What it adds up to
Reflection gives Rusteal the whole engine API without writing it by hand, reification lets Rust classes stand next to C++ and Blueprint ones, and the function tables keep the boundary fast and swappable. The result is a workflow where Rust code is compiled by Cargo alone, reloaded in seconds, and shipped as games or as plugins.
Unreal Engine and Rust
History of Rust and Unreal Engine
Over the years, many attempts have been made to make Rust a viable language for Unreal Engine. This chapter is a brief history of those attempts, and of how the community tested and validated different approaches until Rusteal.
Linking Rust into a plugin (2015)
The earliest public experiment is ue4-static-plugin, a sample UE4 plugin that builds a Rust library with Cargo and links it statically, calling it through extern "C" functions. It offers no bindings to the engine: Rust is a library that C++ calls into. What it proved is that the two toolchains can live in the same build.
Rust as an Unreal module (2020)
In January 2020, a proof of concept by ejmahler went further and made a Rust crate a module of the UnrealBuildTool. C++ modules depended on it as on any other module, headers were generated with cbindgen, and the Rust code was packaged and debugged with the game. The integration lived inside the engine’s own source, so it was shared only with those who had access to Epic’s repository, and require engine re-compilation.
unreal-rust (2022)
The best-known attempt is unreal-rust by Maik Klein, an engineer at Embark Studios, who built it in his spare time and announced it in 2022 for Unreal Engine 5.0. It was a plugin that required no engine changes, and it took an opinionated path: gameplay was written on an Entity Component System of its own, next to Unreal’s actors, with hand-written bindings for the essentials (sounds, spawning actors, pathfinding, physics), hot reload, and Rust components that could be added to actors in the editor.
The project drew thousands of stars and showed how much interest there was in Rust for Unreal. It described itself as a proof of concept, though, and its last commit dates from October 2022. Forks such as elodin-sys/unreal-rust, which started on macOS support, and open-dx/unreal-rs carried it a little further before stopping as well.
A note on Embark. Embark Studios is an interesting case in this story. The studio ships its games, The Finals and ARC Raiders, on Unreal Engine, and it is also one of the major supporters of Rust for games and graphics, with more than forty open source Rust projects of its own (tracked in rust-ecosystem), among them:
- rust-gpu, which makes Rust a first-class language for GPU shaders;
- kajiya, a real-time global illumination renderer written in Rust and Vulkan;
- puffin, an instrumentation profiler for Rust;
- cargo-deny, a Cargo plugin for managing large dependency graphs;
- physx-rs, Rust bindings for NVIDIA PhysX;
- texture-synthesis, example-based texture generation.
Smaller efforts (2024–2026)
Other projects explored the same space with different scopes. unreal_rs worked on Rust bindings for Unreal Engine 5 between late 2024 and early 2025. UERust, in 2026, chose to be “plumbing” rather than a framework: a command-line tool that generates the FFI layer from a configuration file and reloads the Rust library in the editor.
Uika (2026)
Uika, by VioletHelianthus, took a different approach from everything before it. Instead of writing bindings by hand or building a framework beside the engine, it reads the engine’s own reflection:
- a plugin for the Unreal Header Tool exports every reflected class, struct, enum and function to JSON;
- a code generator turns that data into Rust bindings and the C++ wrappers they call;
- Rust structs become real
UClasses, which the editor, Blueprints and the rest of the engine see as their own; - the Rust code compiles to a library loaded by a small C++ plugin, with every call going through a function pointer table, so iterating on Rust needs no C++ build, and the library can be hot reloaded.
Uika targeted Unreal Engine 5.7 and was released in February 2026. A few months later, its author announced that the project would no longer be maintained.
Rusteal
Rusteal is a hard fork of Uika, and it stands on the foundation VioletHelianthus designed: the reflection-driven code generator, the exporter plugin, the reification of Rust structs as UClasses, and the runtime. From there, Rusteal develops the Unreal bindings independently, with the goal of making Rust a language you can ship games and plugins with.
Building on what came before
The earlier attempts are not dead ends: they validated techniques that Rusteal can reuse. The clearest case is static linking, the approach of the first experiments, and it is the path to consoles.
During development, Rusteal loads the Rust code as a dynamic library, which is what makes hot reload possible. A shipped game is a different build: the engine builds consoles monolithically, and their toolchains are C++ only, since no platform holder ships a Rust compiler. Some consoles do load dynamic libraries (Microsoft documents DLLs for the Xbox), but a library for a console is still produced by that console’s linker, from objects compiled for it. Both facts point to the same answer: the Rust code does not need to be built by the console’s toolchain, only linked by it.
The consoles run on x86-64 and on systems close to ones Rust already targets: the PlayStation’s operating system derives from FreeBSD, and the Xbox runs a Windows-based Game OS. Close is not the same, though: a console’s ABI, C library, allowed APIs and unwinding support are its own, so each one needs a Rust target of its own, starting from the nearest public one. Rust compiles the game’s crates into a static library (staticlib) for that target, and the Unreal Build Tool takes it as an external library of a module and links the engine, the plugins and the Rust code into the game’s binary with the platform’s own linker, as it does for any third-party library in a monolithic build. Linking objects rather than sharing LLVM bitcode keeps the Rust compiler and the platform’s compiler independent of each other’s LLVM versions.
Rusteal’s design already fits this path. Every call between the engine and Rust goes through a function pointer table, so with statically linked Rust the plugin fills the table by calling the Rust entry point directly instead of looking it up in a loaded library, and the generated bindings stay the same.
Linking a static library needs no change to the engine: an external module declares it, and Rusteal’s CLI builds it before the engine’s build runs. What is specific to each console (its Rust target, flags, SDK paths and build rules) is covered by the platform holders’ agreements and cannot be published openly, so it would live in a private platform pack, provided with its documentation only to developers who show they have signed Epic’s console access agreement and the platform holder’s. How Rusteal gets there, and what can be prepared before any console access, is in Consoles.
Start here
This handbook is for whoever works on Rusteal itself: its crates, its CLI, its Unreal Engine plugins, its templates and its release. It is meant to be enough on its own: everything a developer new to the project needs to understand it, change it and release it, and the reasons behind how it is built. Using Rusteal to make a game is the other handbook’s subject, the game developer’s handbook; read its first chapters too, since you will test every change the way a game developer uses it.
A reading order
| Day | Read | So that you can |
|---|---|---|
| 1 | Introduction, The repository, Glossary | Name the pieces and find them. |
| 1 | Setting up a checkout | Build the workspace and a test project on your machine. |
| 2 | Architecture, The Rust–C++ boundary | Follow a call from Rust to the engine and back. |
| 2 | Reification and the macros, Types declared in Rust | Follow a #[uclass] from the macro to a class in the editor. |
| 3 | Code generation, The CLI | Follow rusteal build step by step. |
| 3 | The runtime, Errors, Hot reload | Know what the runtime offers and how a reload works. |
| 4 | Testing a change, Common tasks, Known issues and sharp edges | Make a change and prove it works. |
| 4 | Decisions | Know why things are as they are before changing them. |
| 5 | Templates, Releases and versions | Port a template, ship a release. |
A first change, end to end
- Set up the checkout and make a test project from it (
cargo run -p rusteal -- new Probe --dir <dir> --runtime-path .), as Setting up a checkout and Testing a change describe. - Open the project, place a
HelloActor, press Play: the Output Log shows the greeting from Rust. - Change
HelloActor’s message in the test project’s crate, runcargo run -p rusteal -- watch <project>from the checkout, save: the editor reloads the library by itself (see Hot reload). - Change something in Rusteal: a new
ulog!line inrusteal-runtime’sinit, say.cargo run -p rusteal -- build <project>rebuilds the game’s library against your checkout; the reload shows your line. - Change the C++ plugin:
cargo run -p rusteal -- setup <project>, thenbuild --all, and restart the editor. - Run
cargo test --workspaceandcargo clippy --workspace --all-targets -- -D warnings, commit with a Conventional Commits subject on a branch made fromdevelop, and open a pull request todevelop(see The repository).
Rules that are not negotiable
- Game code is safe, idiomatic Rust: a feature is done only when a game developer can use it without
unsafeand without raw handles, in plain Rust; game and plugin crates start with#. - Commits: Conventional Commits subjects (the release computes the version from them), small commits, everything in English. See Tracking documents and conventions.
- The boundary: a change to the API table is made on both sides at once, in the same order (see The Rust–C++ boundary).
- Versions: never edit a version number by hand; the release writes them (see Releases and versions).
- The CI skip marker: never write it in a commit message or a pull request, not even to talk about it.
- Documentation: a change a game developer sees updates the game developer’s handbook; a change to how Rusteal works, or a decision, updates this one (Decisions).
The repository
Everything Rusteal ships comes from one repository, viniciusmorgado/rusteal. This chapter is its map: what each directory is, which branches exist and how a change travels from a branch to a release.
Top level
| Path | What it is | Versioned |
|---|---|---|
Cargo.toml | The Cargo workspace: its members, the shared [workspace.package] (version, edition, license) and the dependencies every crate takes from [workspace.dependencies], the internal ones with both a version and a path (see Releases and versions). | yes |
rust-toolchain.toml | Pins Rust 1.88.0, the MSRV, with clippy and rustfmt, so the checkout cannot use newer Rust by accident. | yes |
rusteal-ue-flags/ | The engine’s reflection flags as Rust constants. | yes |
rusteal-ffi/ | The #[repr(C)] contract between Rust and the C++ plugin. | yes |
rusteal-core/ | The safe runtime over that contract. | yes |
rusteal-macros/ | #[uclass], #[uclass_impl], #[ustruct], #[uenum], #[udelegate]. | yes |
rusteal-runtime/ | What a game crate depends on: re-exports, the prelude, entry!(). | yes |
rusteal-codegen/ | Reflection JSON to bindings crate and C++ wrappers. | yes |
rusteal-cli/ | The rusteal binary (and cargo-rusteal), with the game templates (templates/), the plugin templates and gameplay features (plugin_templates/), the project-file features (project_features/) and the default platform optimizations (optimizations.toml). | yes |
ue_plugin/Rusteal/ | The runtime UE plugin: the Rusteal module (the host of the Rust libraries) and the RustealEditor module. | yes |
ue_plugin/RustealGenerator/ | The build-time UE plugin: the UHT exporter (C#) and its anchor module. | yes |
ide/RustealRules/ | A C# project for language servers only, giving the plugins’ *.Build.cs files their context. | yes |
rusteal.sln | The .NET solution holding the exporter and RustealRules, for C# language servers. | yes |
xtask/ | Contributor tooling: cargo xtask dev-setup. Never published. | yes |
docs/ | Tracking documents: deprecations, the third person gaps, how the variants were ported, the plugin roadmap, hot reload notes (see Tracking documents and conventions). | yes |
assets/ | The logo, the mascot and the template screenshots used by the README. | yes |
.github/workflows/ | CI and the release pipeline (see Releases and versions). | yes |
.cargo/config.toml | The cargo xtask alias. | yes |
.clang-format | LLVM style for the C++ plugin, with SortIncludes: Never: UHT requires a header’s .generated.h to be its last include, which sorting would break. | yes |
.env.example | The contributor settings to copy into .env: RUSTEAL_DEV_ENGINE_ROOT, the engine root. | yes |
.env, compile_commands.json, .cache/ | Written by cargo xtask dev-setup for this machine. | no |
Cargo.lock | Rewritten by every build because of the internal versions; cargo packs its own into the published crates. | no |
rusteal-cli/ue_plugin_embed/ | The plugin snapshot the published CLI embeds, written by rusteal sync-plugin. | no |
rusteal-core/api_table.rs | A copy of the FFI table definition for the published crate, written by rusteal-core/build.rs. | no |
ue_plugin/Rusteal/Source/Rusteal/Generated/ | Codegen output, which only exists inside a game project. | no |
Branches
| Branch | What it is |
|---|---|
develop | The default branch, where work is integrated. Every change reaches it through a pull request, with CI on it. |
main | What is released. It only receives the release pull request from develop, protected by the “main: release checks” ruleset (the build and bump statuses, no bypass); each merge publishes a version. |
feat/..., fix/..., docs/..., chore/... | Work branches, made from an up-to-date develop and merged back into it by pull request. Their names follow the Conventional Commits type of the work. |
ue6 | The adaptation to Unreal Engine 6, apart from main until UE6 ships. Its docs/ue6-radar.md tracks what UE6 changes for Rusteal. |
A change goes: a work branch from develop → commits with Conventional Commits subjects → a pull request to develop (CI) → merge → the release pull request develop → main (Prepare release) → merge → Release publishes and tags → Sync develop brings the version commit back to develop. Before opening a pull request, bring the branch up to date with develop: a branch made before a release still shows the previous version until then.
The handbooks
The two handbooks are mdBooks in repositories of their own, published with GitHub Pages:
| Handbook | Repository | Site |
|---|---|---|
| The game developer’s handbook: using Rusteal | game.rusteal.github.io | viniciusmorgado.github.io/game.rusteal.github.io |
| This handbook: working on Rusteal | dev.rusteal.github.io | viniciusmorgado.github.io/dev.rusteal.github.io |
A pull request that changes what a game developer sees (a command, an attribute, a template) updates the game developer’s handbook in the same round; one that changes how Rusteal works inside, or decides something, updates this one. The README only presents the project and points to both.
Glossary
| Term | Meaning |
|---|---|
| API table | RustealApiTable: the struct of function pointers the plugin hands each Rust library, the plugin’s services (rusteal-ffi/src/api_table.rs, RustealApiTable.h). |
| Bindings crate | The bindings crate codegen writes into a game project’s Rust/bindings/ (or a plugin’s): one Rust type per engine class, struct and enum, committed with the game. |
| Callbacks | RustealRustCallbacks: the seven functions a library returns from rusteal_init, which the plugin calls into Rust. |
| CDO | Class default object: the object holding a class’s defaults, which #[class_defaults] edits. |
| Checked | Checked<T>: a UObjectRef<T> that was checked valid, the type generated instance methods are implemented for. |
| Codegen | rusteal-codegen: reflection JSON to bindings crate and C++ wrappers (step 2 of the build). |
| Current library | The library whose code is running, set before every call into Rust, so what the API creates is tagged with its owner. |
| Deploy | Step 5 of the build: copying a built library to where the plugin loads it, by .partial and rename. |
| Exporter | RustealExporter, the C# UHT plugin writing the reflection JSON. |
| Feature | A piece of gameplay any template can take (a plugin template with a [feature] table), or a set of project files (project_features/). Not to be confused with a Cargo feature, which gates an engine module in the bindings. |
| Func id / function table | The number codegen gives each wrapped engine function, and the table of wrapper pointers indexed by it. |
| Game library | The game’s Rust cdylib, deployed as Plugins/Rusteal/Binaries/<Platform>/librusteal.so, classes in /Script/Rusteal. |
| Hot copy | <stem>_hot_<N>.<ext>: the numbered link or copy of a deployed library the plugin actually loads. |
| Hot reload | Swapping a library while the editor runs (Rusteal.Reload, automatic on deploy). |
| Library | A Rust shared library the plugin hosts: the game’s or a Rusteal plugin’s. |
manual/ | Hand-written extensions codegen copies into every bindings crate, for what reflection cannot describe. |
| Pinned | Pinned<T>: a GC root that keeps an object alive for Rust. |
| Plugin library | A Rusteal plugin’s own library, librusteal_<Plugin>.so, classes in /Script/<Plugin>. |
| Reify / reified class | Creating a Rust type as a real Unreal type at load time; URustealReifiedClass is the class behind a #[uclass]. |
| Reinstance | Moving existing objects and Blueprints to a replaced class, with the engine’s FReload. |
| Rust data | An object’s Rust-private fields, kept by the registry outside the object’s memory. |
| Shape | The compile-time hash of a class’s declarations that decides, on hot reload, whether it is kept or replaced. |
| Stub Blueprint | RS_<Class>, the editor-only UBlueprint a reified class names as its origin. |
| Template / variant | A Rusteal template is an engine template with its gameplay in Rust; a variant (combat, horror…) is one of its genre starting points, a complete project of its own; base is the template alone. |
| Test project | A project made with --runtime-path, following a checkout, where engine-facing changes are tested. |
| UHT / UBT / UAT | The Unreal Header Tool (reflection), Build Tool (compiles C++), Automation Tool (cooks and packages). |
| Wrapper | A generated extern "C" C++ function that calls one engine function natively. |
Architecture
The Rusteal repository holds two worlds that meet at runtime. On one side are the Unreal Engine projects: two UE plugins, written in C++ and C#, that live inside every game made with Rusteal. On the other side is a Cargo workspace: the Rust crates games depend on, the code generator, and the command-line tool that ties everything together.
Two files at the root of the repository list them:
rusteal.slnis a .NET solution for C# language servers. It holds the exporter and an IDE-only project for the plugins’ build rules, so editors resolve the engine’s types in them.Cargo.tomlis the Rust workspace, with every crate.
The C++ modules are in neither. The Unreal Build Tool compiles them inside a game project, and clangd understands them through the compile_commands.json that cargo xtask dev-setup writes (see Setting up a checkout).
This chapter walks through each project: what it is, who uses it, and when you would open it.
The big picture
┌─────────────── build time ───────────────┐
Unreal headers ──UHT──▶ RustealExporter (C#) ──▶ reflection JSON
│
rusteal-codegen
┌─────────────┴─────────────┐
▼ ▼
bindings crate (Rust) C++ wrappers (Generated/)
│ │
┌───────────────┼──────── runtime ──────────┼───────────────┐
▼ ▼ ▼
game crate (cdylib) ──▶ librusteal.so ◀──tables──▶ Rusteal plugin (C++)
rusteal-runtime │
rusteal-core Unreal Engine
rusteal-macros
rusteal-ffi
The rusteal CLI drives the whole pipeline. rusteal build runs it in five steps:
- UE build. The project is built and the exporter writes the reflection JSON.
- Codegen.
rusteal-codegenwrites the bindings crate and the C++ wrappers. - UE rebuild. The engine compiles the wrappers into the plugin.
- Cargo build. The game’s library is compiled.
- Deploy. The library is copied to where the plugin loads it.
The Unreal Engine side
Everything under ue_plugin/ is copied into a game project by rusteal setup (or rusteal new) and listed in its .uproject. There are two separate plugins, each with its own .uplugin descriptor. The engine loads them independently of each other.
The Rusteal plugin (ue_plugin/Rusteal)
The runtime: the plugin that stays in the game, packaged builds included. It has two C++ modules.
The Rusteal module (Runtime)
The bridge between the engine and Rust. It is the largest C++ code base in the repository, and almost everything that happens at the boundary goes through it.
When the module starts, it:
- finds the game’s Rust library (
Plugins/Rusteal/Binaries/<Platform>/librusteal.so) and every Rusteal plugin’s library; - loads each one and checks its version;
- hands each one the API table, a struct of function pointers to the module’s services;
- calls the library’s
rusteal_init.
From then on, Rust calls the engine through that table, and the engine calls Rust through the callbacks the library returns.
Its sources, by role:
-
Module and library host.
RustealModule.cppstarts and shuts down the module and keeps the registry of loaded Rust libraries. It also runs the core ticker and implements theRusteal.Reloadconsole command (hot reload).RustealLibraries.handRustealLibrary.hhold the current library. Before every call into Rust, the module selects the library that owns the class being called, so each library sees only its own objects.
-
The API table.
Public/RustealApiTable.his the C++ side of the contract defined inrusteal-ffi. Each sub-table has its own implementation file:File Services RustealCoreApiImpl.cppObject validity, names, classes and outers, IsA,FName, weak pointers.RustealPropertyApiImpl.cppReading and writing properties through reflection. RustealReflectionApiImpl.cppFinding classes, structs, enums, properties and functions; calling functions by reflection; struct memory. RustealContainerApiImpl.cppTArray,TMapandTSetaccess.RustealDelegateApiImpl.cppBinding Rust code to engine delegates. RustealLifecycleApiImpl.cppGarbage collector roots and pinned objects, which keep objects alive for Rust. RustealReifyApiImpl.cppCreating Rust classes as real UClasses.RustealWorldApiImpl.cppSpawning actors; finding, loading and creating objects; data table rows. RustealWidgetApiImpl.cppUMG widgets. RustealInputApiImpl.cppEnhanced Input bindings. RustealConsoleApiImpl.cppConsole commands and variables. -
Rust classes in the engine.
URustealReifiedClassis theUClasssubclass behind every#[uclass]. It builds the class at runtime, constructs its components, and creates the Rust data of each object.URustealReifiedFunctionis theUFunctionsubclass behind every#[ufunction]. Its native entry point calls back into Rust, so the engine and Blueprints call it like any other function.
-
Delegates.
RustealDelegateProxyis a smallUObjectthat receives a dynamic delegate’s broadcast and forwards it to the Rust closure bound to it. -
Subsystems.
RustealSubsystems.hdeclares the native parents that Rust subsystems derive from: game instance, world, engine and local player. The engine only creates subsystems from native classes, so these turn its virtual functions into events Rust overrides. -
Contract tests.
RustealContractTests.cppholdsstatic_asserts that fail the C++ build if a handle or table layout drifts from its Rust counterpart. -
Build rules.
Rusteal.Build.csdoes three things:- declares the module’s dependencies;
- adds the engine modules the generated wrappers need, from
Generated/module_deps.txt; - stages the Rust libraries in packaged games (
RuntimeDependencies).
The Generated/ directory does not exist in the repository. In a game project, codegen fills it with the C++ wrappers (one small extern "C" function per engine function) and the table that points at them.
The RustealEditor module (Editor)
Exists only in the editor and is left out of packaged games. It holds URustealEditorSubsystem, the native parent for editor subsystems written in Rust. Editor-type Rusteal plugins load this module before registering their classes.
Editor-only features of the runtime go here, so the Rusteal module keeps compiling for game targets.
The RustealGenerator plugin (ue_plugin/RustealGenerator)
Acts only at build time; nothing in it runs while a game plays. Despite its name, it does not generate code: it exports the engine’s reflection, which rusteal-codegen then turns into code. Its two parts are very different in nature.
RustealExporter (C#, a UHT plugin)
Where the work happens. RustealExporter.ubtplugin.csproj is not an engine module but a plugin of the Unreal Header Tool. The build tool compiles it into a .NET assembly and runs it inside UHT, while UHT parses the project’s headers.
RustealExport.cs registers an exporter with [UhtExporter]. It walks every class, struct, enum, property and function UHT knows about, and writes to JSON:
- names and flags;
- property types and offsets;
- function signatures;
- inheritance and interfaces;
- the header each type lives in.
It also filters out the types the generated code could never use.
RustealEnumValues.cs recomputes the enum values UHT leaves unparsed. UHT stores -1 for anything that is not a plain integer literal, such as (1 << 0), but the bindings need the numbers.
When codegen needs a piece of reflection data it does not get yet, this is the project to change.
The RustealGenerator module (C++, a stub)
Empty on purpose: an IMPLEMENT_MODULE and a dummy UCLASS. The exporter declares ModuleName = "RustealGenerator", and that module must appear in UHT’s manifest for UHT to know where the exporter’s output goes. This module only exists to be that anchor; there is nothing to change in it.
RustealRules (ide/RustealRules, IDE only)
The plugins’ *.Build.cs files are C# that the Unreal Build Tool compiles itself, outside any project. A language server would therefore analyze them with no references: nothing resolves, and every using looks unused.
RustealRules.csproj links those files and references the build tool’s assemblies, giving editors the context UBT gives them. Nothing builds or ships this project; it only matters to your editor.
The Rust side
The Cargo workspace has eight members. The first five make up the runtime a game links against; the other three are tools.
rusteal-ue-flags ◀── rusteal-ffi ◀── rusteal-core ◀──┐
▲ ├── rusteal-runtime ◀── game crate
│ rusteal-macros ┘
└──────── rusteal-codegen ◀── rusteal (CLI)
xtask: contributor tooling, not published
All published crates share one version, and so do the UE plugins. The library and the plugin each check the other’s version and refuse to work together if they differ.
rusteal-ue-flags
The engine’s reflection flag constants (EPropertyFlags, EFunctionFlags, EClassFlags and others), mirrored from the engine’s headers. No dependencies, no logic.
Two crates use it: the runtime, which writes flags when it creates Rust classes, and the code generator, which reads them from the JSON. Touch it when a flag Rusteal needs is missing.
rusteal-ffi
The contract between Rust and C++, and nothing else. It has no dependency besides the flags and defines only #[repr(C)] types:
handles.rs: opaque handles to engine objects (UObjectHandle,UClassHandle,FPropertyHandle…);api_table.rs: the API table and its sub-tables, the Rust side ofRustealApiTable.h;callbacks.rs: the callbacks the library hands back to the plugin (create and drop Rust data, invoke a Rust function, tick…);reify_types.rs: the descriptions of Rust classes, properties and functions sent to the reify API;error.rs: the error codes that cross the boundary;version.rs: the version both sides check;contract_tests.rs: compile-time assertions on sizes and offsets, mirroringRustealContractTests.cpp.
Every change here is made on both sides at once: the table in api_table.rs and in RustealApiTable.h, with both sets of contract tests in agreement.
rusteal-core
The safe runtime. All unsafe calls through the API table are confined to this crate, so generated code and game code only see safe types. Its build script reads rusteal-ffi/src/api_table.rs and generates one thin dispatch function per table entry.
Its main modules:
- Object references.
object_ref.rs:UObjectRef<T>;weak_ptr.rsandsoft_ref.rs: weak and soft references;subclass_of.rs:SubclassOf<T>;pinned.rs:Pinned<T>, which keeps an object alive for Rust.
- Data.
struct_ref.rs: engine structs;containers.rs:UeArray,UeMap,UeSet;fname.rs;ue_math.rs: rotators, transforms, colors and bounds, on top ofglam.
- Calling the engine.
dynamic_call.rscalls a function by name, for what the bindings do not cover;world.rsspawns actors and queries the world.
- Rust classes.
reify_registry.rsregisters every#[uclass]and creates itsUClassthrough the reify API. It keeps each object’s Rust data and dispatches the engine’s calls to Rust methods.delegate_registry.rsdoes the same for closures bound to delegates.
- Services.
console.rs: commands and variables;task.rs:spawn,run_on_game_thread,on_tick;input.rs,widget.rsandlogging.rs.
- Errors.
error.rsdefinesRustealErrorandRustealResult;ffi_guard.rscatches panics at the boundary, so a panic in game code is logged instead of unwinding into C++.
rusteal-macros
The procedural macros that make a Rust type an Unreal type:
#[uclass]: a struct becomes aUClass, with its parent, its#[uproperty]fields and its#[component]s;#[uclass_impl]and#[ufunction]: methods becomeUFunctions, overrides of engine events, or console commands;#[ustruct]: a struct becomes aUScriptStruct;#[udelegate]: a multicast delegate declared on a Rust class.
The macros generate typed accessors for each property, plus the registration rusteal-core collects (through the inventory crate) when the library loads. A typo in a macro argument is a compile error, not a silent no-op.
rusteal-runtime
The one dependency a game crate needs. It re-exports rusteal-core, rusteal-ffi, the flags and the macros, and provides rusteal_runtime::prelude.
It has two macros:
entry!()generates the entry points the library exports, which the plugin looks up by name:rusteal_init,rusteal_shutdown,rusteal_versionandrusteal_callbacks;on_load!runs code on every load, hot reloads included.
The engine’s types are not here. They come from the project’s own generated bindings crate.
rusteal-codegen
The code generator. It reads the exporter’s JSON and the project’s rusteal.toml, and writes a library’s bindings:
schema.rsdeserializes the JSON.config.rsreadsrusteal.toml: which engine modules to bind, under which Cargo feature, and what to block.filter.rsdecides what can be generated.type_map.rsmaps engine types to Rust types.naming.rsturns Unreal names into Rust names.rust_gen/writes the bindings crate: one Rust type per class, struct and enum; a typed method per function; accessors per property; and the crate’sCargo.toml, with one feature per engine module.cpp_gen/writes the C++ wrappers and the function table the plugin hands to the library.manual/holds hand-written extensions copied verbatim into every bindings crate: math conversions, collision helpers, world and widget helpers, and Enhanced Input helpers. Put any API that reflection cannot describe here, such as a C++ template.
Generation only rewrites files whose contents change, so an unchanged engine does not trigger rebuilds in UBT or Cargo. tests/manual_compiles.rs generates bindings from fixtures and compiles them, catching codegen regressions without an engine.
rusteal (rusteal-cli)
The command-line tool, published as rusteal. It is what game developers install, and it orchestrates everything above:
rusteal newcreates a UE project from a template and builds it;rusteal setupinstalls the two plugins into an existing project;rusteal buildruns the five-step pipeline, or part of it with--stepor--from;rusteal generateruns codegen alone;rusteal packagebuilds every library in release and cooks the game;rusteal upgrademoves a project to the CLI’s version;rusteal plugin newandrusteal plugin packagecreate and distribute Rusteal plugins.
Its build script embeds the UE plugins and the templates into the binary, so the CLI carries everything it writes into a project. In the workspace it reads ue_plugin/ directly; the published crate falls back to a snapshot produced by rusteal sync-plugin.
templates/holds the game templates: blank, first person, third person, top down, vehicle, intro, AR and VR, with their variants (see Template directories).plugin_templates/holds the plugin templates: blank, editor and inventory, and the gameplay features packaged as plugins: mannequin, checkpoints, interactables and tour (see Features).project_features/holds the features that add files to a project instead of a plugin: rustrover.
xtask
Contributor tooling, never published and never used by games. cargo xtask dev-setup prepares a checkout for working on Rusteal itself:
- reads the engine path from
.env; - asks for the UE project Rusteal is developed against;
- writes
compile_commands.jsonfor clangd, re-targeted atue_plugin/; - writes the exporter’s
.csproj.props; - restores
rusteal.sln.
What a game project contains
The generated code never enters the repository. It lives in each game project, next to the game’s own crate:
MyGame/
├── MyGame.uproject
├── rusteal.toml which engine modules to bind
├── Plugins/
│ ├── Rusteal/ copied from ue_plugin/Rusteal
│ │ ├── Source/Rusteal/Generated/ C++ wrappers (codegen)
│ │ └── Binaries/<Platform>/librusteal.so
│ ├── RustealGenerator/ copied from ue_plugin/RustealGenerator
│ └── Inventory/ a Rusteal plugin, with its own Rust/ and library
└── Rust/
├── Cargo.toml workspace: the game crate and the bindings
├── bindings/ bindings crate (codegen)
└── my_game/ the game crate (cdylib), on rusteal-runtime
Each Rusteal plugin repeats that shape inside its own directory: a C++ module with its own Generated/ wrappers, a Rust/ workspace with its own bindings crate, and its own library.
Examples
The repository has no examples directory: examples/gem_collector, the original demo, was removed in 0.7.0. The reference code for the runtime API is the templates in rusteal-cli/templates/, which every change has to keep building and playing.
The Rust–C++ boundary
Everything that crosses between a Rust library and the engine goes through two tables of function pointers and seven callbacks, all #[repr(C)]. This chapter is that contract: how the plugin loads a library, what each side hands the other, how errors and panics cross, and what to keep in mind before changing any of it. “Private/” and “Public/” below are ue_plugin/Rusteal/Source/Rusteal/Private/ and .../Public/.
Loading a library (Private/RustealModule.cpp)
The Rusteal module is a Runtime module with the Default loading phase (Rusteal.uplugin). StartupModule:
FillApiTable: zeroes the staticGApiTable, sets itsversionto the plugin descriptor’s integerVersion(RustealPluginVersion), and points its twelve sub-tables at the globalsGCoreApi,GPropertyApi…- registers the component list resync and the reify delete listener;
- adds the core ticker (
TickLibraries, which calls every library’son_tick); RustealFillFuncTable(), from the generatedRustealFillFuncTable.cpp: the engine functions’ table (below);- registers the game’s library: the project’s name, the package
/Script/Rusteal, the file<ProjectPluginsDir>/Rusteal/Binaries/<Platform>/<prefix>rusteal.<ext>(librusteal.so,librusteal.dylib,rusteal.dll).
A Rusteal plugin’s own C++ module (from the plugin templates’ {{plugin}}Module.cpp.tera) calls <Plugin>_RustealFillFuncTable() and RustealRegisterPluginLibrary("<Plugin>", <Plugin>_RustealGetFuncTable(), <Plugin>_RustealGetFuncCount()) when it starts, and RustealUnregisterLibrary when it stops: its library is <Plugin>/Binaries/<Platform>/librusteal_<Plugin>.<ext>, its package /Script/<Plugin>. The game’s library loads first, each plugin’s when its module starts.
RegisterLibrary refuses a name already loaded, makes or reuses the FRustealLibrary entry (heap-allocated, so its address is stable), deletes stale _hot_ copies, copies GApiTable into the library’s own Table and sets that copy’s func_table and func_count, loads the library, and broadcasts RustealOnLibraryRegistered.
LoadLibraryFile looks up these exports (Public/RustealApiTable.h has their typedefs):
| Export | Signature | Required |
|---|---|---|
rusteal_version | uint32() | no (missing reads as 0, which fails the check) |
rusteal_init | const FRustealRustCallbacks*(const FRustealApiTable*) | yes |
rusteal_callbacks | const FRustealRustCallbacks*() | no: lets callbacks fired during rusteal_init reach the library |
rusteal_shutdown | void() | no, looked up when unloading |
rusteal_version() must equal the table’s version, or the library is refused with a message asking for rusteal build --all. rusteal_init receives the library’s own table and runs with the library current; a null return means failure, and the library is freed. On the Rust side, rusteal_runtime::entry!() defines the four exports; init checks the table and its version again, stores the table pointer (rusteal_core::api, a OnceLock), records the game thread, installs the panic hook, registers every type (see Reification), runs the on_load! hooks and returns its callbacks.
Versions are encoded as major * 1_000_000 + minor * 1_000 + patch (rusteal_ffi::encode_version, a const fn that refuses minor or patch ≥ 1000 and pre-release suffixes at compile time). Exact equality is the only compatibility check: the table has no size or field count.
UnloadLibrary calls on_shutdown and the rusteal_shutdown export (which calls on_shutdown again: it runs twice and must stay idempotent), forgets the library’s pinned objects and console commands, frees the handle and deletes its load copy. ShutdownModule removes the ticker and unloads every library in registration order. Reloading is in Hot reload.
The current library (Private/RustealLibrary.h)
FRustealLibrary holds a library’s name, package, file paths, reload count, handle, callbacks and its own FRustealApiTable. The current library is a plain static (GCurrentLibrary), set by the RAII FRustealLibraryScope. Every call from C++ into Rust goes through RustealCallLibrary(Library, Fn), which skips an unloaded library and sets the scope: tick, construct, drop, invoke a function, a delegate, a console command, a pinned notification.
The API implementations Rust calls read RustealCurrentLibrary() to tag what they create with its owner: reified classes and functions, delegate proxies, console commands, pinned objects. Later, a call into one of those goes back to the library stored on it. RustealCurrentPackage() gives the package classes, structs and enums are created in: the current library’s, /Script/Rusteal by default, created rooted with PKG_CompiledIn.
The API table
Two hand-written mirrors, field by field in the same order: rusteal-ffi/src/api_table.rs (#[repr(C)] structs of unsafe extern "C" fn fields) and Public/RustealApiTable.h (C structs of function pointers). RustealApiTable is version: u32, twelve sub-table pointers, then func_table and func_count.
| Sub-table | Services |
|---|---|
core | Validity, name, class, IsA, outer, FName, weak pointers, take_error_message, is_child_of |
logging | log(level, utf8), report(RustealErrorReport*) |
lifecycle | GC roots, pinned objects |
property | Typed get and set (bool, integers, floats, string, name, object, enum, struct), soft object paths |
reflection | Finding classes, properties, structs, functions and enums; parameter buffers and call_function; offsets and sizes; struct init, destroy and copy; delegate signatures |
container | TArray, TMap, TSet operations and bulk copies |
delegate | Bind, unbind, multicast add, remove and broadcast, add_function |
reify | Creating classes, structs and enums, and adding their members (see Reification) |
world | Spawning (deferred too), actors of a class, finding, loading and creating objects, the world, data table rows |
widget | Creating widgets, root widget, widget tree |
input | Enhanced Input action binding, touch interface |
console | Commands and variables |
Each C++ sub-table is a global filled by positional aggregate initialization (FRustealCoreApi GCoreApi = {&IsValidImpl, ...} in RustealCoreApiImpl.cpp, and likewise in each Rusteal*ApiImpl.cpp).
rusteal-core/build.rs copies rusteal-ffi/src/api_table.rs into rusteal-core/api_table.rs (gitignored, packaged with the crate, since a published crate cannot read ../rusteal-ffi), parses it with syn, and writes $OUT_DIR/ffi_dispatch.rs: one #[inline(always)] pub unsafe fn <table>_<field>(...) per function pointer of every Rusteal<Name>Api struct, calling ((*crate::api::api().<table>).<field>)(...), plus NativePtr and unaligned native_mem_read/native_mem_write. Raw byte pointers become NativePtr; pointers to other #[repr(C)] structs named Rusteal*, F* or U* are written fully qualified (*const rusteal_ffi::RustealReifyPropExtra). A parameter of a type it does not know makes the generated wrapper fail to compile.
Contract tests check layouts at compile time: rusteal-ffi/src/contract_tests.rs (const asserts, always compiled, so cargo build fails) and Private/RustealContractTests.cpp (static_asserts): handles are 8 bytes, RustealErrorCode 4, RustealReifyPropExtra 40, RustealConsoleArgs 24, RustealErrorReport 72, with their field offsets. They do not check the order or the signatures of the function pointers: that is on the person changing the table.
Adding an API function
- Add the field to its sub-table in
rusteal-ffi/src/api_table.rs, with typesbuild.rsmaps. - Add the function pointer at the same position in
Public/RustealApiTable.h. - Implement it in the matching
Private/Rusteal*ApiImpl.cpp, and add&Implat the same position of the positional initializer. A field appended to the struct but missing from the initializer is silently null. - Return failures with
RustealFail(code, "reason"), so Rust gets the engine’s reason (below). - Add size and offset asserts on both sides for any new shared struct.
- Call it from
rusteal-coreascrate::ffi_dispatch::<table>_<field>(...), behind a safe function; nothing outsiderusteal-corecalls the dispatch. - Rebuild the plugin and the library together: the table changed but the version did not, so an old plugin with a new library is not detected.
The callbacks (rusteal-ffi/src/callbacks.rs)
RustealRustCallbacks, mirrored in RustealApiTable.h and in the same order, is what rusteal_init returns. Each Rust implementation (rusteal-runtime/src/lib.rs) runs inside ffi_boundary.
| Callback | Called by |
|---|---|
construct_rust_instance(obj, type_id, is_cdo) | RustealClassConstructor, once per reified class of the object’s chain, root first; and after a hot reload. |
drop_rust_instance(obj, type_id, data) | The UObject delete listener, once per library of the chain; and before a hot reload. |
invoke_rust_function(callback_id, obj, params) | URustealReifiedFunction::execCallRustFunction. |
invoke_delegate_callback(callback_id, params) | URustealDelegateProxy::ProcessEvent, and console commands (with RustealConsoleArgs). |
on_shutdown() | UnloadLibrary: waits for background tasks, clears the registries. |
notify_pinned_destroyed(obj) | The pinned objects’ delete listener. |
on_tick(dt) | The core ticker, every frame, for every library: runs the queued game-thread jobs and tick hooks. |
The engine functions’ table
Codegen gives every bound engine function an id: build_func_table (rusteal-codegen/src/lib.rs) sorts the functions it can wrap by module, class and name and numbers them from 0; verify_output refuses gaps. The ids are constants on both sides, named after the class and function in SCREAMING_SNAKE case: RustealFuncIds.h (namespace RustealFuncId) and the bindings’ func_ids.rs.
- The wrapper, in
Generated/RustealFunc_<module>_<Class>.cpp, isextern "C" uint32_t <prefix>Rusteal_<Class>_<Func>(void* Obj, ...): it casts the object and calls the C++ function natively, returningRustealErrorCode. It checksIsValidand returnsObjectDestroyedonly in Debug and Development builds. The prefix is empty for the game and<Plugin>_for a plugin, so plugins’ symbols never clash. - The table,
RustealFillFuncTable.cpp, fillsGRustealFuncTable[FUNC_COUNT]by id;RegisterLibraryputs it in the library’s table. - The call, in the generated binding method:
const FN_ID: u32 = <id>, the table entry transmuted to the wrapper’sextern "C"type, called, its code checked withffi_infallible(adebug_assert). Instance methods live on a<Class>Exttrait implemented forChecked<Class>andPinned<Class>only, so the object was checked before the call.
Ids are positions: they change whenever a bound function is added or removed anywhere, and nothing checks the index at run time (func_count is not read on the Rust side). The bindings crate and the C++ Generated/ must always come from the same codegen run, which rusteal build --all guarantees.
Handles and errors
Handles (rusteal-ffi/src/handles.rs): UObjectHandle, UClassHandle, FPropertyHandle, UFunctionHandle, UStructHandle are #[repr(transparent)] pointers; FNameHandle is a u64 (comparison index low, number high); FWeakObjectHandle is {object_index: i32, object_serial_number: i32}, the bytes of an FWeakObjectPtr ({-1, 0} when null).
Error codes (rusteal-ffi/src/error.rs, #[repr(u32)], mirrored by ERustealErrorCode): Ok 0, ObjectDestroyed 1, InvalidCast 2, PropertyNotFound 3, FunctionNotFound 4, TypeMismatch 5, NullArgument 6, IndexOutOfRange 7, InvalidOperation 8, InternalError 9, BufferTooSmall 10.
The engine’s reason (Private/RustealErrors.cpp): RustealFail(code, message) stores {code, message, GFrameCounter} in a thread-local and returns the code. Rust’s check_ffi turns a code into a RustealError and asks core.take_error_message for the reason, which answers only for the same code in the same frame; the message is cleared once fully read. See Errors.
Strings cross as UTF-8 pointer plus u32 length, never NUL-terminated. A string coming back is written into the caller’s buffer, with the full length returned, so the caller can retry with a bigger one.
Validity. UObjectRef::is_valid is ::IsValid (not null, not garbage); checked() gives a Checked<T> or an ObjectDestroyed error. On the C++ side the property and container entry points only check for null, the delegate ones call IsValid, and the generated wrappers check only in Debug and Development: a Rust caller checks first.
Panics (rusteal-core/src/ffi_guard.rs)
ffi_boundary(default, f) runs f under catch_unwind: a panic is reported (error level, as a script error, “Rust panic: …” with the file, line and column the panic hook recorded) and the call returns default. The hook is chained to the one installed before. All seven callbacks use it; init and shutdown use catch_unwind directly (a panic in init returns null and the library is refused). Background tasks, game-thread jobs and tick hooks catch their own panics (task.rs). The callbacks are extern "C", not "C-unwind": a panic must never reach C++, and these guards are what ensure it.
Threads
Nothing on the C++ side asserts the game thread. Engine calls belong on the game thread by convention: Rust code runs there when the engine calls it (functions, events, ticks, delegates), and task::spawn brings a background result back there through the queue on_tick drains. Handles are Send + Sync; UObjectRef, Checked, Pinned, SubclassOf and TWeakObjectPtr are Send but not Sync. GCurrentLibrary is not thread-local; the last error and the pending script error are.
Before changing the boundary
- Order is the ABI.
api_table.rs,RustealApiTable.h, the positionalG*Apiinitializers andFillApiTable, andRustealRustCallbackswith its C++ mirror and__CALLBACKS, must all agree. Only layouts are tested. - Shared structs are
#[repr(C)], namedRusteal*,F*orU*, with contract asserts on both sides. Handles stay 8 bytes; enumsrepr(u32);boolis one byte on both sides. - Versions: the runtime check reads the
.uplugin’s integerVersion; the CLI comparesVersionName. The release writes both (see Releases and versions). - Generated files (
rusteal-core/api_table.rs,Generated/) are build outputs: changerusteal-ffi/src/api_table.rsand the code generator instead.
Reification and the macros
A #[uclass] struct becomes a real UClass when its library loads: Rusteal reifies it. This chapter follows one class from the macro to the engine and back: what the macros generate, where an object’s Rust data lives, how the engine calls a Rust function, and how the C++ side builds the class. The order types are registered in, and enums, interfaces and booleans, are in Types declared in Rust.
The macros (rusteal-macros)
Only four procedural macros exist, and rusteal-runtime re-exports them: #[uclass], #[uclass_impl], #[ustruct] and #[uenum]. #[uproperty], #[component], #[ufunction], #[class_defaults] and #[udelegate] are inert attributes that the outer macro parses and strips.
Names (prop_type.rs): a Rust name becomes the Unreal one in PascalCase (move_speed → MoveSpeed), with b_ turned into UE’s bool prefix (b_can_dash → bCanDash); name = "..." overrides it. A type’s id is the FNV-1a hash of its identifier (not its module path), and a parent’s id the hash of the parent path’s last segment: two Rust classes with the same name in one library collide.
#[uclass] (uclass.rs)
| Argument | Meaning |
|---|---|
parent = Path | Required: the engine or Rust class it derives from. |
implements = "..." / [...] | Interfaces by class path, starting with /. |
config = "Name" | The config category its Config properties are saved in. |
Other arguments are ignored without an error, and so are attributes written on the struct itself.
Fields are of three kinds:
#[uproperty(...)]: a UE property. Flags:BlueprintReadWrite,BlueprintReadOnly,EditAnywhere,EditDefaultsOnly,VisibleAnywhere,SaveGame,Config,Transient,BindWidget,BindWidgetOptional,BindWidgetAnimandBindWidgetAnimOptional(bothTransient); values:default = expr(scalars and enums only),name,category. Anything else is a compile error.#[component(...)]: a default subobject:root,attach = "field",socket = "..."(needsattach),name = "..."(the subobject’s name, PascalCase of the field otherwise).- anything else: a Rust-private field, never seen by the engine.
The flags map to CPF_* as UHT’s do:
| Argument | Flags |
|---|---|
BlueprintReadWrite, BlueprintReadOnly | CPF_BlueprintVisible |
BlueprintReadOnly | CPF_BlueprintReadOnly, and CPF_Edit + CPF_EditConst unless an edit flag is given |
EditAnywhere, BlueprintReadWrite | CPF_Edit |
EditDefaultsOnly | CPF_Edit, CPF_DisableEditOnInstance |
VisibleAnywhere | CPF_Edit, CPF_EditConst |
SaveGame / Config / Transient | CPF_SaveGame / CPF_Config / CPF_Transient |
category and the BindWidget* flags become property metadata, which only exists in editor builds (WITH_EDITORONLY_DATA).
A property’s type is one of bool, i32, i64, u8, f32, f64, UObjectRef<T>, SubclassOf<T>, SoftObjectRef<T>, OwnedStruct<T>, FName, String, an engine or Rust enum (any capitalized path without generics), or a UeArray of scalars, objects, classes, FNames or OwnedStructs. A const _ block asserts at compile time that the parent and every component type are Unreal classes.
The expansion, in order:
- The struct is rewritten to
{ __obj: UObjectHandle, __rust_data: *mut __<Name>RustData }: a handle to the engine object and a pointer to the Rust-private fields, which move to__<Name>RustData. That type’sDefaultcallsDefault::default()on each field, so private fields must implementDefault. UeClass(static_class(), which panics if the class was never registered),HasParent,Inherits<U>through the parent, andDeref<Target = UObjectRef<Parent>>(a pointer cast, relying onUObjectRef’s#[repr(transparent)]layout): a Rust class has its parent’s methods.- For each property, a getter and a
set_setter that look up theFPropertyHandleonce (astatic OnceLockper accessor) and then callproperty_get_*/property_set_*; an array gets a getter returning aUeArrayview. For each private field,x()(a clone),set_x()andx_mut(). For each component, a getter returning it fromreify_find_default_subobject. as_ref(),from_obj(obj)(validity,IsA, then the instance data; null data means the library was reloaded or unloaded),get_default()(the default object).- Hidden registration functions:
__rusteal_register_<name>(find the parent, register theRustTypeInfowhose construct isBox::into_raw(Box::new(Default::default())), callreify_create_class),__rusteal_members_<name>(properties, interfaces, components),__rusteal_finalize_<name>(reify_finalize_class, then thedefault =values on the default object),__rusteal_after_defaults_<name>(reify_set_class_configforconfig). inventory::submit!(ClassRegistration { type_id, shape, depends_on, create, register, finalize, after_defaults }),depends_onbeing the parent’s and the components’ ids.
#[uclass_impl] (uclass_impl.rs)
It takes no arguments and relies on the names #[uclass] generated, so it sits in the same module as the struct.
#[ufunction(...)] accepts BlueprintCallable (the default), BlueprintPure, Override, BlueprintImplementableEvent (at most one of the last three), Exec and name:
| Kind | Flags |
|---|---|
| Callable | FUNC_Native | FUNC_BlueprintCallable | FUNC_Public |
| Pure | the same, | FUNC_BlueprintPure |
| Override | FUNC_Native | FUNC_BlueprintEvent | FUNC_Public |
| ImplementableEvent | FUNC_Event | FUNC_BlueprintEvent | FUNC_BlueprintCallable | FUNC_Public, no FUNC_Native |
Exec | adds FUNC_Exec |
Parameters are scalars, UObjectRef, SubclassOf, UStructRef, enums and OutRef<T>; an override’s parameters come from the parent’s function, not from Rust. A return type of RustealResult<T> (or Result<T, RustealError>) makes the function fallible: Unreal sees T, and an Err is raised as a script error (see Errors). An ImplementableEvent has an empty body, which the macro replaces with a DynamicCall to the Blueprint’s implementation.
#[class_defaults] (one per block, &mut self) runs on the class default object after finalize; a RustealResult<()> it returns is logged on error. #[udelegate] (an empty &self method, name optional) declares a multicast delegate: its body becomes a broadcast, and the macro adds <name>_add(closure) and <name>_add_ufunction(target, "Function").
The expansion registers each function with a closure (register_function, whose returned index is the callback id), then reify_add_function and its parameters with CPF_Parm (and a ReturnValue with CPF_ReturnParm); events get callback id 0. It submits a ClassFunctionRegistration { type_id, shape, register_functions, class_defaults }.
#[ustruct] and #[uenum]
#[ustruct] requires every field to be a #[uproperty] (no default; a nested struct is OwnedStruct<T>). The Rust struct becomes a unit marker type; the fields are reached through a <Name>Ext trait implemented for UStructRef<Name> (memory Unreal owns) and OwnedStruct<Name> (a buffer Rust owns). #[uenum] takes unit variants with discriminants in 0..=254 and makes the enum #[repr(u8)]; see Types declared in Rust.
The shape (shape.rs)
Each macro hashes its declarations: the sorted list of the attribute tokens with ident: Type for fields, or the signature for methods, joined and FNV-1a hashed. Bodies, doc comments, private fields and order do not count. reify_registry::class_shape combines a class’s shape with its impl blocks’, and the plugin compares it on hot reload to keep or replace the class.
The registry (rusteal-core/src/reify_registry.rs)
| Store | What it holds |
|---|---|
TYPE_REGISTRY | type_id → RustTypeInfo (construct and drop functions). |
FUNC_REGISTRY | A Vec of FunctionEntry { callback, type_id }; the callback id is the index. |
INSTANCE_DATA | The object’s address → one InstanceEntry { data, type_id } per reified class in its chain (a Blueprint child of a Rust class of a Rust class has two). |
construct_instance(obj, type_id)builds the data and stores it, replacing an existing entry of that type;drop_instance(obj)drops every entry of the address;clear_alldrops everything when the library shuts down.- A call from the engine reaches
invoke_function(id, obj, params): the entry is cloned, the instance data found by type, and the generated closure runs. It caches the parameters’ offsets (reflection, once), reads each withnative_mem_read(or as aUStructRef/OutRefinto the buffer), zero-initializes the return slot, builds a temporarySelf { __obj, __rust_data }, calls the method, raises anErr, and writes the return value back (reflection_copy_structfor a struct).
The C++ side
URustealReifiedClass
A UBlueprintGeneratedClass subclass, the class behind every #[uclass]: RustTypeId, its Library, its Shape, its first native ancestor (NativeSuperClass) and its component definitions. Being a Blueprint-generated class is what lets the engine create and destroy it at runtime, and Blueprints derive from it; it is also why editor Python sees Rust classes by their Unreal names.
RustealClassConstructor, its ClassConstructor:
- finds every reified class in the object’s class chain, root first (
ReifiedChain), which is how a Blueprint child of a Rust class is constructed; - calls the native parent’s constructor;
- creates each component definition of the chain with
CreateDefaultSubobject, sets its property, and makes it the root or attaches it (to a component of this pass, or an inherited one found by name); - calls
construct_rust_instance(obj, RustTypeId, bIsCDO)for each class of the chain, each through its own library.
RustealReifyApiImpl.cpp
CreateClassImpl: in the library’s package (/Script/Rustealor/Script/<Plugin>, created rooted withPKG_CompiledIn), aNewObject<URustealReifiedClass>(RF_Public | RF_Standalone)with the parent as super struct, itsClassWithin, the inherited class flags without the config ones plusCLASS_CompiledFromBlueprint, andRustealClassConstructor. In the editor, a stubUBlueprintnamedRS_<Name>is itsClassGeneratedBy, up to date and rooted, so the editor’s Blueprint machinery accepts the class. The class is rooted. An existing class is kept or retired by its shape (see Hot reload).AddPropertyImpl: an existing property of the same name is reused (hot reload); otherwise one is created by type and added withAddCppProperty. An enum property is anFEnumPropertyover anFByteProperty.AddFunctionImpl: an existingURustealReifiedFunctionkeeps its object and gets the new callback id and library; a new one is linked into the class’sChildren. A native function getsexecCallRustFunctionas its native thunk; an override copies its parameters from the parent’s function of the same name (CopyParamsFromParentFunction).FinalizeClassImpl: binds and links the functions and the class, assembles the reference token stream, notifies the registration, and creates the default object, which runs the class constructor and therefore the default object’s Rust data. An actor class with its ownReceiveTickgetsbCanEverTickandbStartWithTickEnabled.- Components (
AddDefaultSubobjectImpl) are stored asFRustealComponentDefs with an object property (EditConst,BlueprintReadOnly,InstancedReference…). Interfaces (AddInterfaceImpl) are loaded by path, after engine init when needed, and added asFImplementedInterface(I, 0, true), as for a Blueprint. Config (SetClassConfigImpl) sets the config name and flags and loads the default object’s config.
URustealReifiedFunction::execCallRustFunction
- Finds the reified function:
Stack.CurrentNativeFunction,Stack.Node, the super function chain, or by name in the object’s reified classes. - Gets the parameters: from
ProcessEvent, they areStack.Locals; from the Blueprint VM, they are stepped into a zeroedFMemory_Allocabuffer, with the out parameters’ addresses recorded. - Calls
invoke_rust_function(CallbackId, Self, Params)with the library current. - A pending script error becomes a Blueprint runtime error, on the VM path.
- Copies out parameters and the return value back, and destroys the VM path’s parameters.
Lifetime
The engine owns the object; Rust owns its data. FRustealDeleteListener::NotifyUObjectDeleted calls drop_rust_instance once per library of the object’s reified chain when the garbage collector deletes it. Private fields live on the Rust heap and are not reflected, so the garbage collector does not see objects they reference: a private UObjectRef does not keep its object alive (a #[uproperty] or a Pinned does).
Structs, delegates and subsystems
- Structs.
CreateStructImplmakes aUScriptStruct(BlueprintType), reused on reload;FinalizeStructImplfinalizes nested structs first, then binds, links and prepares the struct ops.UStructRef<T>borrows memory Unreal owns (a parameter buffer);OwnedStruct<T>owns a buffer, initialized and destroyed through reflection. - Delegates.
#[udelegate]adds a signature function<Name>__DelegateSignatureand anFMulticastInlineDelegateProperty(BlueprintAssignable). A broadcast is aDynamicCall::for_delegate. Binding a Rust closure stores it indelegate_registryunder an id and creates aURustealDelegateProxy(outer: the owner) whoseProcessEventforwards the broadcast toinvoke_delegate_callback. ADelegateBindingunbinds when dropped, unlessdetach()ed; a closure re-entered while it runs is skipped. - Subsystems. The engine only creates subsystems of native classes, so
RustealSubsystems.hdeclares abstract native parents that turn the virtuals into events Rust overrides:URustealGameInstanceSubsystem,URustealWorldSubsystem(a tickable world subsystem withReceivePostInitialize,ReceiveWorldBeginPlay,ReceiveTick,bWantsTick,bCreateInEditorWorlds),URustealEngineSubsystem,URustealLocalPlayerSubsystem(ReceivePlayerControllerChanged), and, in the editor module,URustealEditorSubsystem. Each forwardsShouldCreateSubsystem,InitializeandDeinitializetoReceiveShouldCreateSubsystem,ReceiveInitializeandReceiveDeinitialize.
Object references
| Type | What it is | Contract |
|---|---|---|
UObjectRef<T> | #[repr(transparent)] over UObjectHandle | Copy, no ownership, no GC root; is_valid() is ::IsValid; checked() before use; cast() checks IsA, upcast is checked at compile time. The Deref chain casts pointers, so the layout must stay transparent. |
TWeakObjectPtr<T> | An FWeakObjectPtr’s bytes | get() is None once the object is gone. |
SoftObjectRef<T> | A path | get() finds a loaded object; load_synchronous() loads it. |
SubclassOf<T> | #[repr(transparent)] over UClassHandle | base() is T::static_class(). |
Pinned<T> | A GC root (AddToRoot) | Not reference counted: dropping one of two Pinned of the same object unroots it. Pins are released when their library unloads. |
Types declared in Rust
A game’s Rust library declares classes (#[uclass]), interfaces (#[uinterface]), structs (#[ustruct]), enums (#[uenum]) and delegates (#[udelegate]), and the engine sees them as its own: the editor lists them, Blueprints use them, assets save them. None of them exist when the engine starts. The Rusteal plugin creates them when it loads the library, from what the macros registered.
Registration
Each macro submits a registration with the inventory crate, which collects them across the library at link time:
| Macro | Registration | What it creates |
|---|---|---|
#[uenum] | EnumRegistration { create } | A UEnum |
#[uinterface] | InterfaceRegistration { create, register, finalize } | A UClass with CLASS_Interface and its UFunctions |
#[ustruct] | StructRegistration { create, register, finalize } | A UScriptStruct and its properties |
#[uclass] | ClassRegistration { create, register, finalize, after_defaults } | A URustealReifiedClass and its properties and components |
#[uclass_impl] | ClassFunctionRegistration { register_functions, class_defaults } | The class’s UFunctions, delegates and class defaults; on a trait implementation, the interface too |
reify_registry::register_all_from_inventory runs them in dependency order:
- Enums, first, since properties and function parameters of every other kind can hold them.
- Interfaces are created, empty, so classes can implement them.
- Classes are created, empty, parents before children (a pass repeats until no class is left waiting for its parent).
- Structs are created, then given their properties, then finalized (linked, sized).
- Interface functions are added and the interfaces finalized: their parameters can be structs, enums and classes, all created by now.
- Class properties, components and interfaces are added.
- Functions of every class.
- Classes are finalized, then their
#[class_defaults]run on the class default object. Each registration lists the type ids of its parent and of its components’ classes (depends_on), and this step runs in an order where those come first: a class’s default object creates its components from their classes’ default objects, which must be finalized and set up by then.
Every step calls into the plugin through the reify table of the API table (RustealReifyApi): create_enum, create_struct, create_class, add_property, add_function… The objects go in the library’s package: /Script/Rusteal for the game, /Script/<Plugin> for a plugin’s library.
Enums
#[uenum] turns a Rust enum with unit variants into a UEnum:
- the macro keeps the enum as Rust code,
#[repr(u8)], and implementsUeEnumon it (static_enum,to_i64,from_i64), the trait the bindings’ engine enums implement too; create_enumreceives the name, the variant names joined by newlines and their values, and builds theUEnumwithSetEnumsin theEnumClassform (EGrabType::Snap), markedBlueprintType; Unreal adds the_MAXentry, which is why values stop at 254.
Properties of the enum’s type are FEnumPropertys over a byte, which is also how the macros pass enums to and from #[ufunction]s: the parameter is read as the enum’s representation and turned back into the Rust enum with from_i64. A compile-time check refuses an enum stored on more than one byte.
Interfaces
#[uinterface]is ondevelopand comes in the release after 0.7.0; 0.7.0 only lets a class implement interfaces declared in C++ or Blueprint, by class path.
#[uinterface] keeps the trait as Rust code and builds an interface from its functions:
create_interfacemakes a plainUClass(not aURustealReifiedClass: an interface has no instances, so no Rust data) whose parent isUInterface, withCLASS_Interface | CLASS_Abstractand the metadataIsBlueprintBaseandBlueprintType, which is what the editor’s interface picker (FKismetEditorUtilities::IsClassABlueprintImplementableInterface) and variable types look for;- each function is an
add_functionwith no callback andFUNC_Event | FUNC_BlueprintEvent | FUNC_BlueprintCallable, a C++BlueprintImplementableEvent, with its parameters;finalize_interfacebinds and links them, then the class, and makes its default object.
The macro also implements UeInterface on dyn Trait, which is how Rust names the interface (implements::<dyn Interactable>()): Rust has no type of the trait’s name to put it on, and the trait object type is the crate’s own. An inherent impl dyn Trait carries the hidden pieces the other macros read: the interface handle, and for each function its Unreal name and parameter names (__rusteal_signature_<fn>).
A Rust class implements the interface with #[uclass_impl] on impl Trait for Class. That registration adds the interface to the class (add_interface_class, an FImplementedInterface with no pointer offset and bImplementedByK2, as for a Blueprint) and a native function per method, named and with parameters named as the trait’s (read from __rusteal_signature_<fn>, since an implementation may name its parameters differently), with FUNC_Native | FUNC_Event | FUNC_BlueprintEvent: a C++ BlueprintNativeEvent, which a Blueprint child can override. The functions are found by name (FindFunction, which also looks in the class’s interfaces), so a call from a Blueprint message, ProcessEvent or a DynamicCall reaches the class’s function, or the interface’s empty one when the class has none; that is why such a call returns the default value instead of failing.
Rust calls the interface through ScriptInterface<dyn Trait>, which the macro makes implement the trait: each method is a DynamicCall by name on the object, with the trait’s parameter names. core_is_a answers ImplementsInterface when its class is an interface, so UObjectRef::interface and implements are the casts.
Booleans
A bool property or parameter is an FBoolProperty made a native bool (SetBoolSize(sizeof(bool), true)), the kind UHT makes for a C++ bool: it takes a whole byte, written as 0 or 1. FBoolProperty’s constructor makes a one-bit field instead, whose writes change only that bit; Rust reads the byte, so the bits left in a parameter buffer made a false from Python or a Blueprint arrive as true.
Hot reload
A hot reload loads a new library and registers everything again. Classes are replaced; structs and enums are not: create_struct and create_enum find the existing object by name and return it, and warn when its fields or values changed, which takes an editor restart.
Interfaces are rebuilt in place instead. The Blueprints that implement an interface hold its class (FBPInterfaceDescription::Interface), and every class implementing it lists it in Interfaces; replacing the class would leave all of them pointing at the old one. An interface has no instance data, only functions, so create_interface keeps the class when the macro’s shape (the functions’ signatures) changed and retires its functions: they leave the class’s Children and function map, and are renamed out of the way and marked transient, for the bytecode still referring to them until its Blueprint compiles again. The new functions are added and linked, and the function caches of the classes implementing the interface (AllFunctionsCache, which FindFunctionByName fills with interface functions) are cleared. An interface whose shape did not change is kept as it is: add_function and add_function_param do not touch an interface that is not being built.
After the reload, the editor module’s interface refresher (RustealSetInterfaceRefresher) conforms and recompiles the loaded Blueprints that implement or call a changed interface, as the Blueprint editor does when a Blueprint Interface changes, and refreshes the interface’s Blueprint actions. It runs after the classes are reinstanced, since a Rust class implementing a changed interface changed too (its trait implementation is part of its shape) and is replaced, with its Blueprint children recompiled. Without the editor module (-game), a changed interface is kept as it was, with a warning.
Code generation
The bindings are not written by hand: the engine describes itself through UHT’s reflection, an exporter writes that description to JSON, and rusteal-codegen turns the JSON into a Rust crate and the C++ wrappers it calls. This chapter follows that pipeline, its rules, and what to do when it leaves something out.
UHT ──▶ RustealExporter (C#) ──▶ rusteal_{classes,structs,enums}.json
│ copied to Intermediate/Rusteal/uht/
rusteal-codegen
┌───────────────────┴───────────────────┐
▼ ▼
Rust/bindings/ (Rust crate) Plugins/Rusteal/Source/Rusteal/Generated/
func_ids.rs, one module per RustealFunc_<module>_<Class>.cpp,
engine module, manual/, prelude RustealFuncIds.h, RustealFillFuncTable.cpp,
module_deps.txt
The exporter (ue_plugin/RustealGenerator/Source/RustealExporter/)
RustealExport.cs is a plugin of the Unreal Header Tool: [UnrealHeaderTool] static class RustealExport with [UhtExporter(Name = "Rusteal", ModuleName = "RustealGenerator")] on Generate. RustealExporter.ubtplugin.csproj builds it for .NET into Engine/Binaries/DotNET/UnrealBuildTool/Plugins/RustealExporter/, with EngineDir taken from the per-machine RustealExporter.ubtplugin.csproj.props (rusteal setup writes it in a project, cargo xtask dev-setup in the checkout). The RustealGenerator module is an empty editor module: it only exists so UHT’s manifest has a module for the exporter’s output.
It walks every module of the UHT session and writes three indented JSON files next to the module’s UHT output: rusteal_classes.json, rusteal_structs.json, rusteal_enums.json. Step 1 of rusteal build copies them to <project>/Intermediate/Rusteal/uht/.
What it exports:
- Names:
namewithout theA/U/Fprefix,cpp_namewith it,packagethe module’s short name. - Classes: not deprecated, exported from their module (
MinimalAPIorRequiredAPI), and either Blueprint types (up the super chain,BlueprintType/BlueprintSpawnableComponentwin andNotBlueprintTypestops) or with exposed members (aBlueprintVisible/BlueprintAssignableproperty, aBlueprintCallable/BlueprintEventfunction). A class another exported type reaches (its super, a property’s class) is exported too, withreached: true: properties but no functions. - Structs: Blueprint types or with exposed fields, plus the ones reached by supers and properties.
has_static_structis false forNoExportstructs, except a fixed list (Vector,Rotator,Transform…). - Enums: unless
NotBlueprintType. UHT stores-1for values that are not plain literals;RustealEnumValues.csreads the header and evaluates the expression (literals, operators, earlier values, constants from the engine’s third-party headers), and gives up on bodies with preprocessor lines. - Properties: private and protected ones only when Blueprint-visible or editable; not deprecated, not editor-only. With their type, flags,
BlueprintGetter/BlueprintSetter. - Functions: plain functions, not network, private or delegate ones, protected ones only when
BlueprintCallable, not deprecated,NativeorBlueprintEvent. The return value is the last parameter; defaults come fromCPP_Default_<Param>metadata.
When codegen needs a piece of reflection the JSON lacks, the exporter is what to change.
rusteal-codegen
run_generate(root) generates the game’s library from <root>/rusteal.toml; run_generate_plugin(root, plugin) a plugin’s, from its own rusteal.toml but the project’s JSON. Both call generate_library:
config.for_library(kind)(below);- read the three JSON files; build the
CodegenContext; with_unlisted_classes: a module mapping withclasses = [...]keeps those classes and their ancestors within the module, and blocks the rest;filter::apply_filters;build_func_table: the ids of every wrappable function (see The Rust–C++ boundary);- the Rust side: modules,
manual/, the prelude,Cargo.toml; - the C++ side: wrappers, ids, the table;
module_deps.txtand the.uplugin’s engine plugin dependencies;verify_output: contiguous ids, non-emptylib.rs,func_ids.rs,RustealFuncIds.handRustealFillFuncTable.cpp, amod.rsper enabled module; otherwise it exits with an error.
Every file goes through write_if_changed: unchanged output keeps its timestamp, so a full build with nothing new does not make UBT recompile the wrappers nor Cargo the bindings. Stale files are removed one by one, never by wiping the directory.
rusteal.toml (config.rs)
[project] # [plugin] in a plugin, with kind = "runtime" | "editor"
crate = "my-game"
[codegen]
features = ["core", "engine", "input", "slate", "umg", "enhanced-input"]
[codegen.modules]
Engine = { module = "engine", feature = "engine" }
Niagara = { module = "niagara", feature = "niagara", plugin = "Niagara" }
UnrealEd = { module = "unreal_ed", feature = "editor", classes = ["EditorActorSubsystem"] }
[codegen.blocklist]
classes = ["MovieSceneNiagaraSystemSpawnSection"]
structs = ["ConstraintInstanceAccessor"]
functions = ["AnimMontage.IsValidAdditiveSlot"]
- The key of
[codegen.modules]is the UHT module (the JSON’spackage);moduleis the Rust module;featurethe Cargo feature it is generated under;pluginthe engine plugin it comes from, added to the.uplugin;classeslimits a large module to what is needed. - A module is generated when its feature is in
features. An unmapped package is never generated. for_libraryalways adds Rusteal’s own package (Rusteal→rusteal, on the first feature),RustealEditor→rusteal_editorfor editor libraries, and blocks Rusteal’s internal classes (RustealReifiedClass,RustealReifiedFunction,RustealDelegateProxy).- Blocklist functions are
"Class.Function", the class without its prefix and the function’s Unreal name.
Filters (filter.rs)
- Blocked classes disappear; blocked structs are left out of what properties and parameters may use.
- Properties stay when their type is supported, their access allows it, and every class, struct, enum and interface they name is available.
- Functions are dropped when blocked, editor-only in a runtime library, not native, a
K2_XwhoseXexists, or with an unsupported, delegate, nested-container or unavailable parameter. A class from a non-public header loses its functions, and a function naming a type from one is dropped. Remaining duplicate names in a class are numbered (Name_1).
Types (type_map.rs)
| Unreal | Rust |
|---|---|
bool, integers, float, double | bool, i8…u64, f32, f64 |
FString, FText | String (UTF-8 pointer and length across the boundary) |
FName | FNameHandle (uint64_t in C++) |
| enum | the generated enum, through its representation |
| object, soft object, weak object | UObjectRef<Class> |
TSubclassOf<T> | SubclassOf<T> |
| interface | UObjectRef<Interface> |
| struct | opaque bytes; OwnedStruct<FName> in the API when the struct has a static struct |
TArray, TMap, TSet | UeArray, UeMap, UeSet as properties; &[T] in and Vec<T> out as parameters; no nesting |
LazyObjectProperty, SoftClassProperty, FieldPathProperty and plain multicast delegates are unsupported. Parameters are in, out, or in-out by their flags (OUT|CONST is in, OUT|REF in-out); out scalars come back as a tuple, and a parameter with a parseable C++ default becomes an Option.
Names (naming.rs)
to_snake_case:HTTPServer→http_server,K2_GetActorLocation→k2_get_actor_location.- A bool property loses its
b(bHidden→hidden). self,Self,super,crateget a_suffix; other keywords become raw identifiers (r#type); a type file named after a keyword uses#[path].- Collisions: numbered functions, first property wins, a setter is dropped when a function of that name exists or the property has a
BlueprintSetter. - Enum variants drop
_MAX, duplicates and theEnum::prefix; an enum with no variant left becomes a newtype.
The bindings crate (rust_gen/)
Rust/bindings/ (or <Plugin>/Rust/bindings/): package bindings, edition 2024, on rusteal-core, rusteal-ffi and glam; [features] with every enabled feature on by default, each listing the features its modules reference; a README saying the crate is generated and committed with the game.
src/lib.rs:func_ids, one#[cfg(feature)]module per engine module,manual,prelude.- One file per type. A class is a unit struct with
UeClass(static_classthrough reflection),HasParent,Inheritsfor every ancestor, and a<Name>Exttrait forChecked<Name>andPinned<Name>with its property accessors (found by name once,OnceLock), delegate accessors and instance functions; static functions are inherent. Inherited members come from the parent’s trait, not copies. Generated modules start with#![allow(clippy::all)]. - A struct is an opaque
F<Name>with aF<Name>Exttrait forUStructRef<F<Name>>; an enum is#[repr]withUeEnum. src/prelude.rsre-exports a fixed table per feature (core,engine,umg,input,enhanced-input).
manual/ (rusteal-codegen/manual/*.rs) holds hand-written extensions for what reflection cannot describe: vector, quaternion, rotator, transform and color math (core), FKey (input), world, collision, net quantize and data table helpers (engine), widget helpers (umg), Enhanced Input helpers (enhanced-input). build.rs embeds them; codegen writes them into every bindings crate’s src/manual/, each gated by its feature.
The C++ side (cpp_gen/)
Written into the host module’s Generated/:
RustealFunc_<module>_<Class>.cpp: oneextern "C"wrapper per function. A protected function goes throughstruct FAccess : UClass { using UClass::Func; }, aBlueprintNativeEventcallsFunc_Implementation, an interface function casts to the interface, a static one calls the class. Strings are pointer and length (out strings, a buffer and its length), structs are copied as bytes, containers are passed as base and property.RustealFuncIds.handRustealFillFuncTable.cpp(see The Rust–C++ boundary).module_deps.txt:Coreand every enabled package except the host module, one per line;Rusteal.Build.csadds them as public dependencies, a plugin’sBuild.csas private ones.
rusteal setup writes stubs of these files before codegen ever ran, so the plugin compiles from the start.
A plugin’s library
Same pipeline, with [plugin] instead of [project], the project’s JSON as input, <Plugin>/Rust/bindings and <Plugin>/Source/<Plugin>/Generated as output, and every C++ symbol prefixed with <Plugin>_. An editor library (kind = "editor") binds RustealEditor and keeps the editor-only functions. A plugin has one library, runtime or editor, not both.
When something is missing or does not link
- A function is silently absent: a filter dropped it (type unsupported, not native, non-public header…). The Rust generator writes
// Skipped: Class.Func (...)where it falls back. Either make the type supported, add a helper inmanual/, or call it withDynamicCall. - A wrapper compiles but does not link: the function has no
*_APIexport in its module; the exporter only checks exports per class. Add"Class.Function"to[codegen.blocklist] functions, sorted, in every template’srusteal.toml.tera(rusteal-cli/templates/*/*/andrusteal-cli/plugin_templates/*/), and runrusteal build --from 2in the test project. - A wrapper does not compile: block it the same way, with a comment saying why.
Binding a new engine module
- Add
Package = { module = "...", feature = "...", plugin = "..." }to[codegen.modules], and the feature tofeatures, in the templates’rusteal.toml.tera. rusteal build --allin a test project: the exporter sees every module of the session, codegen writes the module, the Cargo feature’s dependencies,module_deps.txtand the.upluginplugin entry.- Blocklist what does not link.
- Optionally, helpers in
manual/and prelude entries (rust_gen/prelude.rs).
Tests
rusteal-codegen/tests/manual_compiles.rs runs codegen on a fixture of real UHT output (tests/fixtures/uht/) and cargo checks the result, without an engine:
| Test | Checks |
|---|---|
manual_compiles_against_generated_bindings | The bindings and manual/ compile with core, engine, input, slate, umg. |
enhanced_input_bindings | The C++ wrappers’ interface casts, FAccess, container returns and includes; the module is removed when its feature is. |
uclass_types_compile | A game crate using every macro compiles against the bindings. |
regenerate_fixture (ignored) | Rewrites the fixture from a project’s JSON: RUSTEAL_UHT_DIR=<project>/Intermediate/Rusteal/uht cargo test -p rusteal-codegen --test manual_compiles regenerate_fixture -- --ignored. |
Unit tests cover the config, names, filters, properties and the table’s symbol prefixes. Run them with cargo test -p rusteal-codegen.
The runtime
rusteal-core is the safe runtime: every unsafe call through the API table is confined to it, so the generated bindings and game code only see safe types. rusteal-runtime is the one crate a game depends on. This chapter is a map of both, and of the editor module that completes them on the C++ side.
rusteal-core, file by file
| File | Role |
|---|---|
lib.rs | Modules and re-exports; lock_or_recover and friends, which recover a poisoned lock instead of panicking. |
api.rs | The table pointer (static API: OnceLock): init_api (once), api() (panics when not initialized), is_api_initialized(). |
ffi_dispatch | Generated by build.rs, one function per table entry (see The Rust–C++ boundary). |
object_ref.rs | UObjectRef<T>, Checked<T>, casts, upcasts. |
weak_ptr.rs, soft_ref.rs, subclass_of.rs, pinned.rs | Weak and soft references, class references, GC roots (see Reification). |
traits.rs | UeClass, UeStruct, UeEnum, HasParent, Inherits, UeHandle, ValidHandle. |
reify_registry.rs | Registering the library’s types, instance data, function dispatch (see Reification). |
delegate_registry.rs | Closures bound to delegates and console commands, by id; DelegateBinding. |
dynamic_call.rs | Calling a function, or broadcasting a delegate, by name through reflection. |
containers.rs | UeArray, UeMap, UeSet views; OwnedStruct<T>. |
struct_ref.rs | UStructRef<T> and OutRef<T> into parameter buffers. |
fname.rs | FName. |
ue_math.rs | Rotator, Transform, LinearColor, Color, Plane, Ray, Sphere, boxes and bounds, on glam. |
world.rs | Spawning, finding, loading and creating objects, actors of a class, data table rows. |
console.rs, input.rs, widget.rs, logging.rs | Console commands and variables, Enhanced Input binding, widgets, ulog!. |
task.rs | Background work, the game-thread queue, tick hooks. |
error.rs, ffi_guard.rs | RustealError and panics (see Errors). |
Threads and ticks (task.rs)
The thread that runs rusteal_init is recorded as the game thread. Then:
run_on_game_thread(f)pushesfonto a queue; it runs on the next tick.spawn(work, done)runsworkon arusteal-taskthread and queuesdone(value)for the game thread. A panickingworkis logged on the game thread; a thread that cannot start is logged too. Running tasks are counted.on_tick(f)registers a hook called every tick with the delta time, until itsTickHandleis removed (the handle is#[must_use]: dropping it keeps the hook forever).tick(delta), theon_tickcallback: drains the queue, then calls the hooks, each undercatch_unwind, without holding the lock while user code runs, so a hook may add or remove hooks.shutdown(), fromon_shutdown: waits for running tasks (warning after a second), then clears everything, so no thread runs code that is unloaded.
On the C++ side, StartupModule adds TickLibraries to the core ticker (FTSTicker::GetCoreTicker()), which calls every library’s on_tick each frame.
Calling by name (dynamic_call.rs)
DynamicCall is what the bindings do not cover: functions defined in Blueprints, a BlueprintImplementableEvent’s implementation, delegates.
DynamicCall::new(obj, "Function")checks the object, finds the function by reflection (function_not_foundotherwise) and allocates its parameter buffer;for_delegate(obj, property)takes the delegate’s signature instead.set(name, value)finds the parameter and its offset and writes the value (anFNamethrough the property, to keep its display index);set_structcopies a struct.call()runsreflection_call_function;broadcast()a multicast delegate. The result’sget(name)reads an out parameter or the return value.- The buffer is freed when the call or its result is dropped.
The macros generate DynamicCalls for BlueprintImplementableEvent bodies and #[udelegate] broadcasts, logging a failure as a warning and returning a default.
Data
- Containers.
UeArray<T>,UeMap<K, V>andUeSet<T>areCopyviews, an owner and a property: nothing is cached, each call crosses the boundary (len,get,set,push…), andto_vec,bulk_iterandset_allcopy everything in one call, growing the buffer once when it is too small. Elements implementContainerElement: primitives (raw-copyable), handles and references,String(length then UTF-8),OwnedStruct<T>(bytes). - Structs.
OwnedStruct<T>owns a buffer sized, initialized and destroyed through reflection (new); copies made from bytes do not destroy.UStructRef<T>borrows memory Unreal owns;OutRef<T>writes an out parameter. - Math.
Rotatoris in degrees and converts to and fromDQuat(pitch clamped at ±90°);Transformis aDQuat, aDVec3translation and aDVec3scale;LinearColorconverts to and fromVec4. The engine’s large world coordinates are doubles, hence theDtypes.
Services
- Console (
console.rs):register_command(name, help, closure)stores the closure indelegate_registryand registers the command, which receives its arguments and aCommandContextwhoseworld::<W>()checks the world’s handle into a typed reference; a name already registered is refused.ConsoleVariable<T>(bool,i32,f32,String) registers a variable and reads and writes it as text. The plugin forgets a library’s commands when it unloads. - Input (
input.rs):bind_action_raw(actor, action, trigger, "Function")binds an Enhanced Input action to a#[ufunction], whose parameters must be a prefix of(FInputActionValue, ElapsedSeconds, TriggeredSeconds, UInputAction*); the actor needs anUEnhancedInputComponent.should_display_touch_interface()is the Slate check the templates use for touch controls. - Widgets (
widget.rs):create_widget_raw(owner, class)picks the owner asCreateWidgetdoes (player controller, world, game instance). - Logging: games log through the
logcrate.rusteal-runtime/src/ue_log.rsinstalls alog::Logwhen the library initializes (init), withInfoas the default maximum level, and writes each record to theLogRustealcategory:error!as an error,warn!as a warning, the rest at display level. Each library has its ownloginstance, so the game’s and every plugin’s loggers are independent. Underneath,ulog!(level, ...)(logging.rs,LOG_DISPLAY,LOG_WARNING,LOG_ERROR) is the raw call, kept for older projects; it needs the API initialized.
rusteal-runtime
What a game crate depends on, and nothing else:
- re-exports:
rusteal_coreasruntime,rusteal_ffiasffi,rusteal_ue_flagsasue_flags,glam, the macros, andinventory(hidden) for the macros’ registrations; __CALLBACKS: the seven callbacks, each inffi_boundary;init(table): the checks and the registration described in The Rust–C++ boundary;entry!(): the four exports (rusteal_init, which first records the crate’s source root for error locations,rusteal_shutdown,rusteal_version,rusteal_callbacks);on_load!(function): a hook run at the end of everyinit, hot reloads included;prelude: the references, containers, structs, errors, math types, handles,uclass/uclass_impl/ustructand theglamtypes a game uses most.uenum,ulog!,bail!,ensure!andtaskare reached through the crate (rusteal_runtime::runtime::ulog).
The engine’s types are not here: they come from the project’s own bindings crate.
The editor module (ue_plugin/Rusteal/Source/RustealEditor/)
Rusteal.uplugin declares RustealEditor as an Editor module: it depends on editor-only modules (UnrealEd, BlueprintGraph, DirectoryWatcher, MessageLog, EditorSubsystem), which a game target cannot link, so the runtime module stays free of them and exposes plain hooks the editor module fills in (RustealSetClassReinstancer, RustealSetSourceOpener). Its StartupModule:
- registers the Rusteal Message Log listing (filters, no pages, clearable);
- sets the source opener:
FSourceCodeNavigation::OpenSourceFile(file, line, column), or the default application; - sets the class reinstancer (see Hot reload);
- starts the auto reload.
It also declares URustealEditorSubsystem, the native parent of Rust editor subsystems, which the editor plugin template derives from. ShutdownModule undoes all four.
Testing the runtime
There is no test double of the API table: a test that reaches ffi_dispatch panics in api(). The unit tests (inline #[cfg(test)] modules) exercise the code paths that do not need the engine, and the runtime keeps those paths separate on purpose (is_api_initialized() guards):
| File | Tests |
|---|---|
error.rs | Codes, formatting, contexts, locations, std conversions, bail!/ensure!, source paths, size and Send/Sync. |
ffi_guard.rs | A panic returns the default. |
task.rs | spawn, on_tick, removal, shutdown, driven by calling tick() by hand. |
console.rs | Value parsing. |
ue_math.rs | Rotator and color conversions. |
subclass_of.rs | Hand-written UeClass probes. |
reify_registry.rs | Shape combination and the dependency order. |
What needs the engine is tested in a project (see Testing a change). CI runs cargo build --workspace, cargo test --workspace and cargo clippy --workspace --all-targets --no-deps -- -D warnings, all without Unreal.
Errors
An error in Rusteal says what failed, on which object, why Unreal refused, and where in the user’s Rust code, and it reaches the places a game developer looks: the return value, the Output Log, the Message Log and, under a Blueprint call, a Blueprint runtime error. How it is used is in the game developer’s handbook, Errors; this chapter is how it is built.
RustealError (rusteal-core/src/error.rs)
Modelled on std::io::Error: a struct, matched by kind(), not an enum of variants. It is one Box<Inner>, so RustealResult<()> is a pointer wide (a test checks it), and it is Send + Sync and implements std::error::Error, so it works with ?, anyhow and user error types.
Inner holds:
| Field | What it is |
|---|---|
kind | ErrorKind, #[non_exhaustive]: ObjectDestroyed, InvalidCast, PropertyNotFound, FunctionNotFound, TypeMismatch, NullArgument, IndexOutOfRange, InvalidOperation, BufferTooSmall, Internal, Other. |
message | The name involved, or the user’s message. |
engine | The engine’s reason, from take_error_message. |
subject | The object’s and class’s names, read when the error is made. |
object | A weak handle to the object, for the Message Log link. |
location | The caller’s Location: every fallible core function, macro accessor and generated binding is #[track_caller]. |
frames | The contexts, each with its own location. |
source | The cause, a Box<dyn Error + Send + Sync>. |
- Constructors (all
#[track_caller]):new(kind),msg(..)(Other),other(error)(aRustealErrorstays itself), and one per kind (object_destroyed(),invalid_cast_to(obj, target),property_not_found(name)…). - Builders:
context(..),on(&object)/on_handle(h)/on_class(..)(the first subject wins),with_source. - Context on results:
ErrorContextforResult<T, E: Error>andOption<T>(.context(..),.with_context(..));bail!andensure!;Fromfor the common std errors. - Formatting:
{e}is one line (context: kind: message (subject): Unreal: reason);{e:#}adds the causes and the location;{e:?}is the full report, withat file:line:columnand awhile ...line per context.
The engine’s reason
The plugin’s implementations fail with RustealFail(code, message) (Private/RustealErrors.cpp), which keeps the message in a thread-local with the frame number; RustealFailType writes the type mismatches (“Health (FloatProperty of BP_Enemy_C) is not a BoolProperty”). On the Rust side, check_ffi(code) makes the error and, for every code but ObjectDestroyed, takes the message with core.take_error_message, which only answers for the same code in the same frame, so a stale reason never attaches to a later error. check_ffi_ctx(code, ctx) uses the context as the message for the “not found” and invalid operation codes, as a context frame otherwise. ffi_infallible is for calls that cannot fail: a debug_assert only.
Reporting
RustealError::log(level), the macros’ generated code (__raise, __log_in) and the panic guard send a RustealErrorReport through logging.report: level, whether it is a script error, the one-line summary, the full report, the weak object, the absolute source file (relative locations are joined to the crate’s directory, recorded by entry!() with set_source_root(env!("CARGO_MANIFEST_DIR"))), line and column.
RustealReport on the C++ side:
- appends the Blueprint script call stack to the details;
- writes
[Rusteal] ...toLogRustealat the matching verbosity; - for a script error, keeps the summary as the thread’s pending script error;
- on the game thread only, adds an entry to the Rusteal Message Log: the summary, a link to the object (an object of Play In Editor falls back to the level’s actor of the same path once Play stops, through
UWorld::RemovePIEPrefix), and afile:linelink that calls the editor module’s source opener; and for an error, an editor notification, at most every five seconds.
Fallible functions and Blueprint runtime errors
A #[ufunction] returning RustealResult<T> is a function returning T to Unreal. When its body returns Err, the generated closure calls __raise("Struct::Function"): the error is reported as a script error with that prefix, and the function returns the default value. execCallRustFunction then takes the pending script error and, when the call came from the Blueprint VM (not from ProcessEvent), raises it with FBlueprintCoreDelegates::ThrowScriptException as a UserRaisedError: in Play In Editor, a “Blueprint Runtime Error” in the PIE Message Log with a link to the node. A NonFatalError would not show there, which is why the error type is UserRaisedError.
Panics
ffi_guard::install_panic_hook chains a hook that records the panic’s location in a thread-local, before the previous hook runs. ffi_boundary catches the panic and reports it as a script error, “Rust panic: message”, at that location, then returns the callback’s default. A panic under a Blueprint call therefore shows like any fallible function’s error.
Hot reload
A Rust library is swapped while the editor runs: save a file, rusteal watch builds and deploys it, the editor reloads it, and the objects in the level carry on with the new code. This chapter is how the pieces fit, with the files to read for each. The repository’s docs/hot-reload.md keeps the longer notes: shapes in detail, the timings, and what was and was not tested.
The loop
save a .rs file
└─ rusteal watch (polls every 400 ms, settles 300 ms)
└─ rusteal build: cargo build, then deploy
└─ copy to librusteal.so.partial, rename over librusteal.so
└─ RustealAutoReload (DirectoryWatcher, quiet for 0.5 s)
└─ RustealReloadLibrary(Name)
├─ 1. drop the Rust data of the library's objects
├─ 2. unload, load librusteal_hot_<N>.so, rusteal_init
├─ 3. reinstance the replaced classes (FReload)
├─ 4. stop here if the load failed
└─ 5. construct the Rust data again
Registering and loading a library (RustealModule.cpp)
StartupModulefills the API table, registers a delete listener and the core ticker (TickLibraries, which calls each library’son_tick), and registers the game’s library: the project’s name, the package/Script/Rusteal, the file<Project>/Plugins/Rusteal/Binaries/<Platform>/librusteal.<ext>. A Rusteal plugin’s C++ module registers its own withRustealRegisterPluginLibrary(<Plugin>/Binaries/<Platform>/librusteal_<Plugin>.<ext>, package/Script/<Plugin>).RegisterLibrarydeletes the stale load copies (<stem>_hot_*.<ext>) a crash may have left, and broadcastsRustealOnLibraryRegistered, which the editor module listens to.LoadLibraryincrements the library’s reload count and makes the load copy<stem>_hot_<N>.<ext>: a hard link on Linux and macOS, a copy where that fails or on Windows (a loaded DLL’s file stays locked), the deployed file itself if both fail. It checks the copy’srusteal_versionagainst the plugin’s descriptorVersion, callsrusteal_init(&Table)with that library current (FRustealLibraryScope) and keeps the callbacks it returns.UnloadLibrarycalls the library’son_shutdownandrusteal_shutdown, forgets its pinned objects and console commands, frees the handle, and deletes its load copy.
Loading a copy is what lets the build replace the deployed file at any time. It is also why the deploy step must replace the file (.partial then rename, a new inode) and never write into it: the load copy may be a hard link to the old inode, which the running editor has mapped.
Reloading (ReloadLibrary)
Each phase is timed and logged:
drop_rust_instanceon every object of aURustealReifiedClassowned by the library. The#[uproperty]fields live in the objects’ memory and stay; the Rust-private fields are dropped and come back fromDefault.- Unload, then load the new copy, whose
rusteal_initregisters every class again. RustealReifyReinstanceReplacedClasses().- If the load failed, stop and log why.
construct_rust_instanceon every object, saying whether it is a class default object.
Rusteal.Reload [Names...] (an FAutoConsoleCommand of the runtime module) reloads the named libraries, or all of them.
Kept or replaced: the shape
#[uclass] and #[uclass_impl] compute a shape at compile time (rusteal-macros/src/shape.rs): an FNV-1a hash over the sorted declarations, the class’s arguments, its #[uproperty] and #[component] fields and its #[ufunction] and #[udelegate] signatures. Method bodies, doc comments, private fields and the order of declarations are left out.
CreateClassImpl (RustealReifyApiImpl.cpp) finds the existing class by name:
- same shape and parent: the class is kept, with its new
RustTypeIdandLibrary.AddFunctionImplkeeps each existingURustealReifiedFunctionand only gives it the newCallbackIdandLibrary, so a call goes to the new code. This is the common case, a change to a function’s body, and it costs nothing. - changed, with a reinstancer:
RetireClasscuts the old class and its functions from the library (Library = nullptr, so they do nothing), renames it and its stub Blueprint toRUSTEAL_<Name>in the transient package, and roots it; a new class is created and the pair is queued for reinstancing. - changed, without a reinstancer (a
-gameprocess): the class is kept as it was, with a warning that only the editor can apply the change.
Structs and enums are not hot reloaded: create_struct and create_enum return the existing object and warn when its fields or values changed, which takes an editor restart.
Reinstancing (RustealEditorModule.cpp)
The editor module registers ReinstanceClasses with RustealSetClassReinstancer:
FReload Reload(EActiveReloadType::Reinstancing, "RUSTEAL", *GLog);NotifyChange(New, Old)for each replaced class, thenReinstance(): the engine moves every object, Blueprint child and default object to the new class, as it does for Live Coding;CompileBlueprintsUsing: the engine recompiles the replaced class’s children, and Rusteal also recompiles the loaded Blueprints that only use it (a Level Blueprint calling one of its functions), which would otherwise call a function that no longer exists;Finalize(false), with garbage collection skipped, so a reload does not stall the editor.
FReload lives in UnrealEd, which is why reinstancing is the editor module’s job and a -game process has none.
Reloading on deploy (RustealAutoReload.cpp)
The editor module watches each library’s deploy directory with the engine’s DirectoryWatcher, for the libraries already registered (RustealForEachLibrary) and the ones registered later (RustealOnLibraryRegistered). Only a change to the deployed file itself counts: .partial and _hot_ files are ignored. Once the file has been quiet for half a second (SettleSeconds), the ticker reloads that library and shows an editor notification, for 3 seconds on success and 6 on failure. Rusteal.AutoReload 0 turns it off.
Limits
| Change | Result |
|---|---|
| A function’s body, a private field’s use | The class is kept; the functions point to the new code. |
A #[uproperty], #[component], #[ufunction] or #[udelegate] added, removed or changed | The class is replaced and reinstanced, its Blueprint children and users recompiled. |
A #[ustruct] or #[uenum] changed | Kept as it was, with a warning: restart the editor. |
| A function a Blueprint uses removed | The Blueprint’s node breaks, as it would with C++. |
| Private Rust fields | Reset to Default on every reload. |
The C++ plugin, the bindings, rusteal.toml | Not reloadable: rusteal build --all and an editor restart. |
Why not Live Coding
Live Coding is Windows-only and patches the machine code UBT compiled; a library Cargo built has nothing for it to patch. What Rusteal needs from it is the reinstancing, and that part, FReload, is exported from UnrealEd (UNREALED_API) and usable on every platform. The function table makes the code swap itself trivial: since every reified function calls through its library and callback id, loading the new library and pointing the functions at it is the whole swap.
The CLI
rusteal-cli builds two binaries: rusteal, which game developers install, and cargo-rusteal, which makes every command a Cargo subcommand. This chapter is how the commands work inside, file by file. What they do for a game developer is in the game developer’s handbook’s CLI reference.
Commands (src/main.rs)
The command line is a clap Parser; rusteal --version is the crate’s version.
| Command | Flags | Code |
|---|---|---|
new <Name> | --dir (.), --template (blank), --variant, --runtime-path, --feature (repeatable), --no-build, --optimizations on|off | new_cmd::run_new |
plugin new <Name> | --template (blank), --project, --no-build | plugin_cmd::run_plugin_new |
plugin package <Name> | --project, --output (<root>/Packaged/Plugins) | package_cmd::run_plugin_package |
setup <project> | setup::run_setup | |
build [project] | --all, --step N, --from N, --release, --plugin | build_cmd::run_build |
watch [project] | --plugin, --optimizations | watch_cmd::run_watch |
generate [project] | rusteal_codegen::run_generate, then run_generate_plugin for each plugin | |
package [project] | --output (<root>/Packaged), --shipping, --optimizations | package_cmd::run_package |
upgrade [project] | upgrade_cmd::run_upgrade | |
sync-plugin | sync_plugin::run_sync |
- Where a build starts (
first_build_step):--from Nstarts at step N;--alland--stepstart at 1 (--stepruns that step only); otherwise step 4 (build_cmd::RUST_STEP), so a plainrusteal buildruns the Rust steps, 4 and 5.--allconflicts with--stepand--from, and both must be 1 to 5. - The project (
project_root) is the first directory holding a.uproject, walking up from the given one or the current one (rusteal_codegen::config::find_project_root). - The version check runs before every command except
new,upgradeandsync-plugin(see Versions).
src/bin/cargo-rusteal.rs drops the rusteal argument cargo inserts, looks for the rusteal binary next to itself and then on PATH, and execs it on Unix (spawns it and forwards the exit code elsewhere). cargo rusteal watch is therefore rusteal watch, the same command line on every platform, which is what IDE run configurations use.
The build (src/build_cmd.rs)
HostPlatform::CURRENT is chosen with #[cfg(target_os)]: the CLI only builds for the platform it was compiled for.
| Platform | UBT | UAT | Library |
|---|---|---|---|
| Linux | Engine/Build/BatchFiles/Linux/Build.sh | RunUAT.sh | lib<name>.so |
| Windows | Engine/Build/BatchFiles/Build.bat (Win64) | RunUAT.bat | <name>.dll |
| Mac | Engine/Build/BatchFiles/Mac/Build.sh | RunUAT.sh | lib<name>.dylib |
The libraries (BuildContext::new):
- the game’s, unless
--pluginis given: the workspace<root>/Rust, the crate and features ofrusteal.toml’s[project], deployed to<root>/Plugins/Rusteal/Binaries/<Platform>/with the stemrusteal; - one per Rusteal plugin, every
<root>/Plugins/*holding arusteal.toml(find_plugins, sorted by name): the workspace<plugin>/Rust, the[plugin]crate and features, deployed to<plugin>/Binaries/<Platform>/with the stemrusteal_<Plugin>.--pluginmatches a name case-insensitively.
The five steps:
- UE build.
<engine>/<UBT> <Project>Editor <Platform> Development -Project=<uproject> -NoHotReload(run_ubt; on Windowscanonical_no_prefixstrips the\\?\prefix). UHT runs the exporter during it; the step then copiesrusteal_classes.json,rusteal_structs.jsonandrusteal_enums.jsonfromPlugins/RustealGenerator/Intermediate/Build/<Platform>/UnrealEditor/Inc/RustealGenerator/UHT/to<root>/Intermediate/Rusteal/uht/. - Codegen.
run_generatefor the game,run_generate_pluginfor each plugin (see Code generation). - UE rebuild. UBT again, which compiles the generated wrappers.
- Cargo. It needs
<workspace>/bindings/src/func_ids.rs(missing: “runrusteal build --allonce”), then runscargo build --manifest-path <workspace>/<crate>/Cargo.toml(or the workspace’s with-p <crate>), with--releaseand--featuresas asked. - Deploy. The library in
target/<debug|release>/is copied to<dest>.partialand renamed overlibrusteal.so(librusteal_<Plugin>.so). The deployed file is replaced, a new inode, never written into: the plugin hard-links it into its load copy (see Hot reload), and the editor’s directory watcher must never see a half-written library.
-NoHotReload keeps UBT from producing hot-reload module names, so a build with the editor open does not fight the editor’s own modules. The CLI does not check whether the editor is running: steps 1 to 3 rebuild C++ the open editor has loaded, which is why changing the plugin or the bindings needs an editor restart, and steps 4 and 5 are made to run with it open.
The profiles come from the template’s Rust/Cargo.toml (see the game developer’s handbook, Development and release builds): dev is opt-level = 1 for the game crate, 3 with line-tables-only debug info for the dependencies and the bindings (full debug info makes the library pass 100 MB, reloaded on every change); release is codegen-units = 1 and thin LTO.
run_cmd echoes the command line (cut at 200 characters) and exits with the child’s code on failure; run_cmd_code returns the code instead, for callers that must clean up first (package).
Watching (src/watch_cmd.rs)
Polling, so it needs no dependency and behaves the same on every platform: every 400 ms (POLL) it snapshots (mtime, size) of the .rs and .toml files of the game’s Rust/ and each plugin’s (--plugin filters), skipping target, bindings and directories starting with .. On a change it re-snapshots every 300 ms (SETTLE) until two agree, prints the changed files (up to three), and runs <itself> build <root> [--plugin P] as a child process. A failed build is logged and the loop goes on; it never returns. Changes to rusteal.toml or the engine need rusteal build --all, which watch does not run. It writes the platform optimizations once, when it starts.
Setting up a project (src/setup.rs)
- Writes
PLUGIN_FILES(the embedded plugins) under<project>/Plugins/, skipping files already identical. - Writes
Plugins/RustealGenerator/Source/RustealExporter/RustealExporter.ubtplugin.csproj.propswith<EngineDir>, so the exporter’s C# project finds the engine. - Without a
rusteal.toml, renders the blank template’s (templates::render_rusteal_toml), with the crate namecrate_name_forderives from the project (MyGame→my-game). - Adds
RustealandRustealGeneratorto the.uproject’sPlugins. - Unless
RustealFuncIds.hexists, writes C++ stubs inPlugins/Rusteal/Source/Rusteal/Generated/(RustealFuncIds.hwithFUNC_COUNT = 0,RustealFillFuncTable.cpp,module_deps.txtwithCore,CoreUObject,Engine), so the plugin compiles before codegen ever ran; plugins get the same with the prefix<Plugin>_.
The project’s .gitignore comes from the templates (.gitignore.tera), not from setup.
Creating a project (src/new_cmd.rs)
- The name: ASCII letters and digits, starting with a letter, at most 20 characters (Unreal’s limit for project names).
- The template and variant (
templates::select), then the features: the template’sfeaturesand the--featureones, without duplicates. - The engine template (
Template::load) is read with the engine’s own recipe,Templates/<engine_template>/Config/TemplateDefs.ini:FoldersToIgnore,FilesToIgnore,FolderRenames,FilenameReplacements,ReplacementsInFiles(case-insensitive unlessbCaseSensitive),SharedContentPacks. A template withoutSource/is Blueprint-only; the headers ofSource/<Template>/named after it are the classes to redirect. - Instantiation copies the engine template, skipping the ignored paths, its
.uprojectand the manifest’sexclude, applies the renames and, incpp,h,iniandcsfiles, the replacements (%TEMPLATENAME%,%PROJECTNAME%and their cases), and copies the shared packs intoContent/<Pack>. - The
.uproject:EngineAssociationfromEngine/Build/Build.version, modules renamed; a Blueprint-only template gets aModulesentry andwrite_game_modulewritesSource/<Project>/(two targets, aBuild.cson Core, CoreUObject, Engine, InputCore and EnhancedInput, the module files). - The project ini: a
ProjectIDwhenDefaultGame.inihas none;ActiveGameNameRedirectsand oneActiveClassRedirectsper template class inDefaultEngine.ini; the manifest’sdefault_mapasEditorStartupMapandGameDefaultMap. - The Rusteal files of the template, rendered with
project,crate_name,version,glam_versionand, with--runtime-path(which must holdrusteal-runtime/Cargo.toml),runtime_path. - The bindings placeholder crate, the platform optimizations,
setup, the features (a plugin throughplugin_cmd::create_plugin, files throughtemplates::write_project_feature_files), then the five build steps unless--no-build, and the next steps.
Templates and embedding (build.rs, src/templates.rs)
build.rs embeds everything the CLI writes into the binary, with include_bytes!:
| Generated file | Constant | From |
|---|---|---|
plugin_files.rs | PLUGIN_FILES | ../ue_plugin, or ue_plugin_embed/ in the published crate |
template_files.rs | TEMPLATE_FILES | templates/ |
plugin_template_files.rs | PLUGIN_TEMPLATE_FILES | plugin_templates/ |
project_feature_files.rs | PROJECT_FEATURE_FILES | project_features/ |
It skips Generated, Binaries, Intermediate, obj and *.csproj.props. In the repository it reads ue_plugin/ directly; the published crate has no ue_plugin/ next to it, so it reads the snapshot rusteal sync-plugin writes into rusteal-cli/ue_plugin_embed/ (gitignored, written by the release workflows). A local snapshot can be stale without harm, since the workspace build prefers ue_plugin/.
templates.rs reads the manifests: a template is <name>/<variant>/template.toml (Manifest, deny_unknown_fields: description, engine_template, exclude, default_map, features, next_step), with a mandatory base; names match loosely (case and -/_ ignored). A plugin template’s PluginManifest may carry [feature] (plugin, shared_packs); a project feature has a ProjectFeatureManifest. write_files renders {{ }} in paths and *.tera files with Tera, dropping the suffix. How to add one is in Template directories.
Plugins and packaging (src/plugin_cmd.rs, src/package_cmd.rs)
plugin new: a name of 1 to 32 letters and digits, starting with a letter, notRusteal,RustealGenerator,RustealEditornor the project’s; a feature carryingContent/keeps its fixed plugin name. It renders the template intoPlugins/<Name>/(contextplugin,crate_name,version,glam_version, andruntime_pathwhen the project follows a checkout), writes the bindings placeholder and the prefixed C++ stubs, enables the plugin in the.uproject, copies its shared packs and builds.package: the five steps in release, aSnapshotof the host’sConfig/<Platform>/*.ini, the platform optimizations,RunUAT BuildCookRun -project= -platform= -clientconfig=<Development|Shipping> -build -cook -stage -pak -archive -archivedirectory=<out> -nop4 -unattended -utf8output, the snapshot restored, then UAT’s exit code.plugin package: a release build of that plugin, then a copy of its directory withoutIntermediate,Saved,DerivedDataCache,Rust/target,obj, and the_hot_,.debugand.pdbfiles ofBinaries/.
Versions (src/project_version.rs, src/upgrade_cmd.rs)
A project’s pins are rusteal-runtime, rusteal-core and rusteal-ffi in Rust/Cargo.toml’s [workspace.dependencies], all three either exact ("=X.Y.Z", Pins::Exact) or paths into a checkout (Pins::Checkout, relative paths resolved from <root>/Rust), and in agreement. compare gives Matches, ProjectOlder, ProjectNewer or OtherCheckout: a checkout project matches only the CLI built from that same checkout (CARGO_MANIFEST_DIR/..). check looks at the project and every plugin; with Scope::PinsAndPlugins it also wants VersionName of both .uplugins to be the CLI’s. Every refusal names its fix (rusteal upgrade, cargo install rusteal@X, cargo run --manifest-path ..., rusteal setup).
upgrade refuses checkout projects and newer projects, then rewrites only the three pin lines in the game’s and every plugin’s workspace to "=<CLI version>", deletes Plugins/Rusteal and Plugins/RustealGenerator, runs setup and the full build.
The engine path (src/global_config.rs)
<config dir>/rusteal/config.toml (~/.config/rusteal/config.toml on Linux) holds [engine] path. When it is empty, the CLI asks once for the engine root, until the directory has Engine/Build/BatchFiles, and saves it; without a terminal it exits with the setting to write. This is the only per-machine setting a game project depends on; contributors have .env for theirs, which the CLI never reads.
Tests
| File | What it protects |
|---|---|
tests/plugin_version.rs | Both .uplugins carry the workspace version (VersionName) and its integer form (Version), which the runtime handshake and the version check rely on. |
main.rs | The clap definition, the version, the default build steps, --all’s conflicts, repeated --feature, the --optimizations switch. |
build_cmd.rs | The atomic deploy leaves no .partial. |
watch_cmd.rs | Ignored paths, detected changes, the change description. |
templates.rs | The template list, valid manifests, bundled features that exist, the default optimizations in every rusteal.toml.tera, the feature invariants, the RustRover files, loose names, rendered paths. |
new_cmd.rs | Writable copies, the game module of Blueprint-only templates, the default map. |
package_cmd.rs | The plugin package exclusions. |
project_version.rs, upgrade_cmd.rs | Pin parsing and comparison; rewrite_pins touches only the pins. |
optimizations.rs | The optimizations’ block, user values, untouched files, the snapshot. |
bin/cargo-rusteal.rs | Argument forwarding. |
setup.rs, global_config.rs and sync_plugin.rs have no tests: they are exercised by rusteal new in a test project (see Testing a change).
Platform optimizations
Some engine settings belong to one platform: a workaround for a driver, a default that suits one RHI. Rusteal declares them per platform in the project’s rusteal.toml and the CLI writes the ones of its own platform into the engine’s per-platform config files. This chapter is how that works, why it is built this way, and what to keep in mind when adding one.
The first one: Vulkan’s fence on NVIDIA
The reason the mechanism exists. On Linux, with an NVIDIA card (seen with drivers 595.84 and 595.104.02, engines 5.8.2 and 5.8.3, an RTX 4060, COSMIC on Wayland with the editor under XWayland), the editor segfaulted one to three times a day inside the driver:
vkWaitForFences
FVulkanFence / FVulkanSwapChain::AcquireImageIndex
FVulkanViewport::Present
Every one of those crashes came within two seconds of AcquireNextImage() failed due to the outdated swapchain in the log. The swapchain goes out of date when a window changes: a secondary window closes (Project Settings, Message Log, the PIE window), PIE stops, a window is resized. The engine then recreates the swapchain, passing the old one as oldSwapchain, and acquires the next image with a CPU fence, because r.Vulkan.CpuWaitForFence is 1 by default; vkWaitForFences crashes in the driver. It happened in the Project Browser too, with no project open and no DLSS, so neither a project nor Streamline (stubbed on Linux) was the cause.
r.Vulkan.CpuWaitForFence=0 makes the engine wait on a GPU semaphore and never call vkWaitForFences there. Epic already ships it as the default on Android (Engine/Config/Android/AndroidEngine.ini: “Use semaphores instead of fences for image wait”). The variable is ReadOnly: the console refuses it, but it is read at RHI start-up from the [ConsoleVariables] section of the Engine config, so it has to be in an .ini, not set from code at run time. With it in place the crashes stopped.
A packaged game runs the same VulkanRHI, and its swapchain goes out of date on resize, fullscreen toggles, alt-tab and resolution changes: players on Linux with NVIDIA are exposed too. On Windows the projects use DirectX 12 and the path never runs.
Where an .ini setting can go
| File | Read by | Notes |
|---|---|---|
Engine/Config/ConsoleVariables.ini, [Startup] | the editor and Development builds | Compiled out of Shipping and Test (#if !DISABLE_CHEAT_CVARS): never for something the game needs. |
Engine/Config/Linux/LinuxEngine.ini, [ConsoleVariables] | every project and every game packaged with that engine install, and the Project Browser | Lost when the engine is reinstalled or upgraded (a 5.8.4 install replaces Engine/Config). |
<Project>/Config/Linux/LinuxEngine.ini, [ConsoleVariables] | that project’s editor and game, on Linux only | Versioned with the project; survives engine upgrades; this is the one Rusteal writes. |
The engine one, as it was added on the development machine, in the section the engine’s file already has:
[ConsoleVariables]
; larger timeout since drivers may take longer time
g.TimeoutForBlockOnRenderFence=60000
memory.MemoryPressureCriticalThresholdPercent=10
; Use semaphores instead of fences for swapchain image wait (avoids NVIDIA driver crash in vkWaitForFences after an out-of-date swapchain)
r.Vulkan.CpuWaitForFence=0
and the project one, in a project that had no Config/Linux before:
[ConsoleVariables]
; Use semaphores instead of fences for swapchain image wait (avoids NVIDIA driver crash in vkWaitForFences after an out-of-date swapchain)
r.Vulkan.CpuWaitForFence=0
Both are needed on a machine that also shows the crash in the Project Browser: the engine’s file covers what runs before a project is loaded; the project’s file is what goes with the game when it is packaged elsewhere (another machine, CI) or with an engine that does not have the change. Startup confirms either in the log:
LogConfig: Set CVar [[r.Vulkan.CpuWaitForFence:0]]
An engine-level change is per machine and invisible to the project: keep it out of what Rusteal manages, and repeat it after every engine upgrade.
Declared in rusteal.toml
[optimizations.linux.Engine.ConsoleVariables]
"r.Vulkan.CpuWaitForFence" = 0
[optimizations.<platform>.<File>.<Section>], with the platforms linux, windows and mac and any config file name (Engine, Game, Input, …). The section name is the .ini section, quoted when it has dots or slashes ("/Script/Engine.RendererSettings"). Values are TOML strings, integers, floats or booleans, written as the engine reads them (True, False).
The default table is rusteal-cli/optimizations.toml, embedded in the CLI (optimizations::DEFAULTS). Every template’s rusteal.toml.tera ends with the same text, so a new project shows its optimizations and the game developer edits them in place. every_template_carries_the_default_optimizations fails if a template drifts from the defaults: change optimizations.toml and the templates together. A project whose rusteal.toml has no [optimizations] table, every project made before this existed, gets the defaults; an empty [optimizations] table means none. Unknown platforms and non-scalar values are refused when the file is read.
Written by the CLI, chosen at compile time
rusteal-cli/src/optimizations.rs. The platform is not detected at run time: optimizations::HOST is a #[cfg(target_os = ...)] constant, as build_cmd::HostPlatform::CURRENT. The Linux CLI only ever writes optimizations.linux into Config/Linux/, the Windows CLI only optimizations.windows into Config/Windows/. A Windows machine never touches the Linux settings, and the engine stages Config/Linux for Linux builds only, so a Windows package carries none of them.
The settings are the same on every Linux machine, whatever its GPU. Writing them only where an NVIDIA card is found was rejected: the file is versioned, and machines that disagree would rewrite it back and forth in every commit; the player’s GPU is unknown at package time anyway; and the setting costs nothing on other drivers.
apply(root, switch) goes over the .ini files of Config/<Platform>/ and those the table names:
- strip the block between
BEGINandEND(two comment lines:; Rusteal optimizations, ...: beginand; Rusteal optimizations: end), keeping the user’s text around it; - with
Switch::On, render the table as its own sections inside a new block, leaving out every key the user’s text already sets in that section (the user’s value wins, and the key is not written twice); - write the user’s text and the block, or delete the file when nothing is left of it, which is the case of a file Rusteal created itself.
A file with no Rusteal block and nothing to add is never rewritten, not even to normalize its line endings. Writing a separate section block at the end of a file that already has [ConsoleVariables] is valid because the engine merges repeated sections: FillFileFromBuffer (ConfigCacheIni.cpp) calls FindOrAddSectionInternal for every section header. The user’s own lines must stay outside the markers: whatever is between them is rewritten.
When it runs
| Command | Call |
|---|---|
rusteal new | optimizations::report("new", ...) once the project files exist, before the plugins are installed. |
rusteal watch | report("watch", ...) before the first snapshot: each developer, on each machine, gets the settings by starting the watch. |
rusteal package | Snapshot::take, report("package", ...), BuildCookRun (build_cmd::run_cmd_code, which returns the exit code instead of exiting), Snapshot::restore, then the exit code. |
--optimizations <on|off> (Switch::parse: on/off, 1/0, true/false; on by default) is on the three commands. watch --optimizations off removes the block until the next watch; package --optimizations off ships without it and restores the project as it was, so the developer’s editor keeps whatever state it had. A package interrupted with Ctrl+C skips the restore; the next rusteal watch rewrites the block.
rusteal build does not write them: the build must not change versioned files behind the user’s back, and the editor reads the .ini when it starts, which new or watch has already prepared.
Adding one
- Find the root cause and check the setting on the platform’s editor and on a packaged game: the engine log shows
LogConfig: Set CVar [[name:value]]when a console variable is applied at start-up. - Check whether it can be set at run time instead. A setting the game code can change after start-up belongs in Rust, behind
#[cfg(target_os = "...")], where the game crate is compiled per platform; the.iniroute is for what the engine reads before Rust is loaded (ReadOnlyconsole variables, RHI and platform settings). - Add it to
rusteal-cli/optimizations.tomlwith a comment saying what it fixes, and append the same lines to everyrusteal-cli/templates/*/*/rusteal.toml.tera;cargo test -p rustealchecks they agree. - Document it in the game developer’s handbook (
cli/optimizations.md) and in this chapter.
Existing projects pick a new default up only if their rusteal.toml has no [optimizations] table; the others list their own and add it by hand, which the release notes must say.
Consoles
Rusteal builds for Linux and Windows; consoles are not supported yet. This chapter is how Rusteal gets to PlayStation 5 and Xbox Series X|S (and to any platform whose Unreal support is private), what can be prepared now in the public repository, and what can only be done once a project has console access. It comes from two research reports made on 11 October 2026 from public sources and from Rusteal’s own code, without access to any console SDK: what they could confirm is marked as such, and what depends on the SDK is listed as open.
The goal behind the plan is concrete: a game made with Rusteal ships on PC, does well, and its publisher gets it console access. From that moment the publisher wants a release date, so the work left after access has to be as small and as predictable as possible.
Conclusions
What the research and the review of Rusteal’s own code concluded, on 11 October 2026:
- No Rust support from the platform holders is needed. Rust produces native code; what matters is whether that code links and runs in the platform’s environment. Looking for an official “Rust SDK” for a console is the wrong question.
- Linking Rust into a console build needs no engine fork. A static library consumed by an external module, built by Rusteal’s CLI before UBT runs, uses the engine’s documented mechanisms for every platform. Patching UBT is justified only by a limitation measured with the SDK in hand.
- The artifact is a
staticlibper target. Whether a console links it into the executable (route S) or turns it into a dynamic library (route D), the console build starts from it. Some consoles do load dynamic libraries, but that does not remove the need for a Rust target the platform’s linker accepts. - The biggest obstacles are in Rusteal, not in the consoles. The runtime relies on each library being a separate dynamic library: fixed export names, one table per runtime instance, positional function ids, short-name type ids, link-time registration, identical bindings package names. An aggregated static build breaks all of them, and all of them can be fixed on desktop, in public, without an SDK.
- The largest console-specific unknown is the Rust target and its standard library, above all on PS5. Making the runtime take its services from the engine (allocation, tasks, time), which is already ported to every console, shrinks that unknown to “the crates compile and link for the target”.
- The repository stays public; console specifics do not. Rusteal keeps a platform-agnostic structure and an extension point; each console’s target, flags, build rules and CI live in a private platform pack.
- The aim is a short, predictable path after access. Everything that does not need the SDK is done before it, so that once a publisher obtains access, the remaining work is the toolchain bring-up and validation on the devkit, not a redesign.
What console access is
Three separate permissions, none of which replaces the others:
| From | What it gives | What it does not give |
|---|---|---|
| The platform holder (Sony, Microsoft) | Developer status for the organization, the SDK, devkits, the submission process | Epic’s console code |
| Epic | Access to the engine’s Platform Extensions for the approved platforms, through the Epic Developer Portal, after the platform holder’s approval | The SDK, which Epic does not redistribute |
| The publisher | The relationship, the title, often the request for access | Permissions for each organization and person, which are granted by the parties above |
The console build of Unreal is the engine built from source with the platform extension added, against the SDK version that extension supports; Epic’s AutoSDK layout and Turnkey help install and verify it. That is the normal way any studio builds for a console; it is not a fork of the engine.
What Rusteal adds on top must not depend on anything else from that list. In particular, no Rust support from the platform holders is needed or expected: Rust produces native code, and the question is only whether that code links and runs in the platform’s environment.
Public structure, private platform packs
Rusteal stays open source; console code cannot be. The line between them:
| Public, in this repository | Private, per platform, under the agreements |
|---|---|
| The static backend, the module contract, the services the runtime takes from the engine | The platform’s Rust target definition (target JSON, sysroot), flags and build-std settings |
| The CLI’s notion of a target platform and its extension point for platform definitions | The definition files the CLI loads for each console |
| The external module that consumes a static library | The Build.cs branches and staging rules for the console |
| The desktop fixture and its tests | Patches to the Rust standard library, if a console needs them |
| This chapter | CI with the SDK and the devkits, and the platform’s own documentation |
A private platform pack per console holds the right-hand column, and is given only to developers who show they have signed Epic’s console access agreement and the platform holder’s. Its licensing is a separate decision.
How Rust reaches a console build
The engine’s build is not changed. The parts:
- Rust compiles to a static library. For each target and configuration, Cargo builds a
staticlib: an archive of native objects the platform’s linker can consume like any C++ library. - An external module consumes it. A module of type
ModuleType.Externaladds the archive withPublicAdditionalLibraries, and its include paths and system libraries; the module that needs the Rust code depends on it. This is the documented way to integrate a third-party library, and it works the same way for every platform. - The CLI orchestrates.
rustealalready runs steps before and after the engine’s build (see The CLI). For a console it runs Cargo for the target, checks the result against a manifest, and only then runs UBT and UAT. In CI the same sequence can be a BuildGraph graph (Spawnthe Rusteal step, thenCompilethe game). - UBT links, with the platform’s linker. The game’s binary contains the engine, the plugins’ C++ and the Rust archive; cooking, staging and packaging are the engine’s and the platform extension’s, unchanged.
Why not patch UBT. UBT’s documented hooks (external modules, PreBuildSteps, BuildGraph) are enough to link a library built beforehand. A patch would only be justified by a need measured with the SDK in hand: Rust actions inside UBT’s own graph and distributed executor, for instance. It would have to be kept up with every engine version, and it would not help if the platform rejected the Rust objects themselves, which is a toolchain question.
Why a static library and not loose objects or bitcode. A staticlib carries every object a crate graph needs, in the form the platform’s linker expects; loose objects (--emit=obj) do not include the dependencies, and an rlib is Rust’s internal format. Bitcode would tie the Rust compiler’s LLVM version to the platform compiler’s. Cross-language LTO is out of scope until a first build works.
Two routes from the same archive
| Route | What ships | Rusteal changes | Depends on |
|---|---|---|---|
| S, static (the default) | One aggregated Rust archive linked into the game’s executable | The module contract below | Only the platform’s linker accepting the archive |
| D, dynamic | One dynamic library per Rusteal library, produced by the platform’s linker from its archive | Little: the libraries stay isolated as today | The platform allowing game code in dynamic libraries, and the engine’s FPlatformProcess::GetDllHandle working there |
Unreal builds consoles monolithically, and that is the path platform certification knows best, so S is the one to prepare; D stays a fallback where a platform supports it. Microsoft documents DLLs for Xbox titles; for PS5, nothing is publicly verifiable. Either way the console build starts from the same staticlib per library.
The editor keeps the dynamic libraries and hot reload: the static backend is for packaged games only, built from the same crates.
What the static backend has to change
Today each library (the game’s and each Rusteal plugin’s) is a cdylib loaded on its own, and several parts of the runtime rely on that isolation. In one aggregated archive they would collide:
| Today | In one archive | The change |
|---|---|---|
entry!() exports the same four symbols (rusteal_init…) from every library | Duplicate symbols, or the linker keeps one of them | One entry point for the aggregate; each library exposes a descriptor instead |
rusteal-core/src/api.rs keeps one table pointer in a OnceLock | The second library to initialize panics, or uses the first one’s table | A context per library (module), with the shared engine services apart from the library’s own function table |
| The generated functions’ ids are positions in each library’s function table | Index 7 of plugin A is a different function from index 7 of plugin B | Each bindings crate resolves its own table through its module’s context |
| A type’s id hashes its short name | Inventory::Item and Quest::Item collide (already a known issue) | Ids qualified by module and path |
Registration relies on inventory’s link-time collection | Nothing guarantees the linker keeps every registration from an archive | An explicit list of descriptors, referenced by the aggregate, checked for completeness at startup |
Every generated bindings crate is the package bindings 0.1.0 | Cargo refuses a crate graph with two of them (reproduced with a minimal workspace) | A unique package name per library, imported as bindings through a Cargo alias |
| The handshake compares the Rusteal version | Two generations of bindings with the same version are not told apart | A fingerprint of the bindings (functions and signatures) checked before any call |
on_shutdown runs twice per unload | A module without a library handle has no unload to rely on | One shutdown state machine, safe to call once or twice, after a partial init too |
The C++ side keeps its per-library tables and package paths (/Script/<Plugin>): those must not change, since cooked Blueprints refer to Rust classes by them. What changes is that a library is identified by a context, not by a loaded file.
Depend on the engine, not on the operating system
The Rust standard library talks to the operating system: threads, time, locks, thread-local storage, the allocator, unwinding. On a console those parts of std are the hardest to port. Unreal, on the other hand, is already ported to every console it supports. The runtime can take its services from the engine through the API table instead of from std:
| Service | Today | Through the engine |
|---|---|---|
| Allocation | The system allocator | A GlobalAlloc over FMemory::Malloc/Free, available before the first Rust allocation |
| Background work | task::spawn on std::thread | The engine’s task system |
| Time | std::time::Instant | FPlatformTime |
| Logging | The log crate and ulog! already go to the engine | (unchanged) |
With those, the runtime needs little more than core and alloc on a console, and a port of std becomes a smaller and optional piece of the platform pack. Game code can keep using std on PC; on a console the rule is that it uses Rusteal’s services rather than the operating system directly.
Panics. Consoles’ C++ is usually built without exceptions, and unwinding may not be available to Rust there. If a console needs panic = "abort", ffi_boundary’s catch_unwind no longer turns a panic into a logged error: the process ends, through the platform’s crash reporting. Expected failures are already RustealResults; the change is a documented policy per platform, not a different API.
The Rust toolchain per console
The open part of the plan, and the one each platform pack carries:
- A target. No public Rust target names a console, and none is assumed. A console’s target starts from the nearest public one (
x86_64-pc-windows-msvcfor Xbox,x86_64-unknown-freebsdfor PS5) as a custom target, then matched to the platform’s ABI: calling convention, relocation model, TLS model, object format, unwinding. - The standard library. Building
coreandallocfor a custom target is routine withbuild-std;stdneeds an implementation of its system layer for the platform, which is why the runtime leans on the engine for services. On Xbox, the Game OS exposes a partition of the Windows API: imports outside it show up at link time, and the C runtime must match the engine’s. - Proof before scale. The first console experiment links one function with no heap, threads or I/O into a minimal Unreal project that already runs in C++ alone, then adds allocation, a callback from the engine, registration, tasks.
Road ahead
The port has two phases. The first is done now, on desktop and in public, before any console access: it is where most of the work is, and doing it first removes it from the critical path of a console release. The second starts the day a publisher obtains access, and is kept as short as possible by the first.
Before access: on desktop, in public
| Milestone | Work | Done when |
|---|---|---|
| 1. Module contract | Contexts per library, qualified type ids, explicit registration, unique bindings package names, bindings fingerprint, one shutdown state machine; the dynamic backend keeps working | A fixture with the game and two Rusteal plugins passes: calls from A to B and back reach the right library, two classes with the same short name coexist, a library’s tasks and delegates keep their owner |
| 2. Static backend on desktop | rusteal package --static: one aggregated staticlib, an external module consuming it, a manifest of the artifact; the editor stays dynamic | The fixture, packaged in Development and Shipping on Linux and Windows, starts from a clean machine with no Rust dynamic library, cooks and loads a Blueprint derived from a Rust class |
| 3. Target platforms in the CLI | Host and target separated; unknown targets refused instead of falling back to .so; platform definitions loaded from outside the repository | A platform definition file for desktop drives the same build as the built-in one |
| 4. Engine services | The allocator, tasks and time through the API table | The runtime builds without the std features it no longer needs, and the desktop fixture behaves the same |
After access: with the SDK and the devkits
In the private platform pack, on top of the four milestones above:
| Step | Done when |
|---|---|
| 5. C++ baseline | A minimal Unreal project builds and runs on the devkit with the platform extension, with no Rust |
| 6. Smallest Rust slice | One function from Rust links and runs in that project |
| 7. The fixture | The desktop fixture runs on the devkit: allocation, callbacks, registration, cooked content, tasks, shutdown |
| 8. The game | The title’s own build, its certification requirements, its crash and debugging tools |
Without the first phase, steps 5 to 8 would also carry the module contract, the static backend and the runtime services, under the publisher’s date; with it, the work left is the toolchain bring-up and validation. A firm estimate needs the SDK, but the schedule risk is concentrated in one item: each console’s Rust target.
A failure at each step has a known owner: a context, registration or cook problem on PC is Rusteal’s; a symbol or service missing at the console’s link is the platform pack’s; a cold start failing after a good build points to registration order or runtime services; a project that works for its author but not from a clean checkout is a reproducibility problem, to fix before calling anything shippable.
Open until there is access
- Whether each platform accepts objects from the Rust compiler as they are, and which target definition matches its ABI.
- Which parts of
stdand which C runtime the Rust objects can use on each console. - Whether PS5 allows game code in dynamic libraries (route D).
- Debugging and profiling Rust code with the consoles’ tools: symbols, mixed C++ and Rust stacks, crash dumps.
- The exact build, packaging and submission steps of each platform extension, which are not public.
Sources
The two research reports behind this chapter (11 October 2026), and the public documentation they rely on, among it:
- Epic: Getting started with console development, Integrating third-party libraries, Module properties, Build tool target reference, BuildGraph, AutoSDK.
- Microsoft: Developing a new title with the GDK, vcpkg on Xbox.
- Rust: Linkage, Custom targets, build-std, FFI in the Rustonomicon.
Porting an engine template
rusteal new --template <name> starts from one of the engine’s own templates and puts its gameplay in Rust. This chapter is how a template gets there, from the engine’s C++ or Blueprints to rusteal-cli/templates/<template>/<variant>/.
What a Rusteal template is
A directory under rusteal-cli/templates/ per template and variant, embedded in the CLI by its build.rs:
| File | What it is |
|---|---|
template.toml | The manifest: the engine template it starts from, the paths of it to leave out, the default map, the line printed at the end. |
rusteal.toml.tera | The project’s Rusteal configuration: the modules to bind, by feature. |
Rust/ | The Cargo workspace and the game crate, with the template’s classes. |
Content/ | The template’s assets that differ from the engine’s: the Blueprints reparented to the Rust classes, the levels resaved with them. |
Source/ | The game module’s build rules, when the template needs other engine modules. |
rusteal new copies the engine template with the engine’s own recipe (its Config/TemplateDefs.ini: ignored folders and files, renames, text replacements, shared content packs), then writes the template’s files over it. A .tera file is rendered with the project’s name and crate name first.
C++ templates
The engine’s C++ templates (TP_ThirdPerson, TP_VehicleAdv…) have their gameplay in C++ classes and data in Blueprint children of those classes. The port:
- Writes the Rust classes, one per C++ class, with the same Unreal names, properties, functions and events. C++ virtuals become the engine events Rust overrides (
TickisReceiveTick,SetupPlayerInputComponentisReceiveRestarted). - Redirects the C++ classes to the Rust ones while the template’s assets are resaved:
[CoreRedirects]with+ClassRedirects=(OldName="/Script/TP_X.Class",NewName="/Script/Rusteal.Class"). A resaved Blueprint names its Rust parent from then on. - Leaves the C++ out: the manifest’s
excludelists the C++ files and the other variants’ content.
The placed actors of a World Partition level are the exception: each actor’s descriptor stores its first native class, and a Rust class is not native, so a redirect to it leaves the descriptor invalid. Those levels are resaved with the old class redirected to /Script/Engine.Actor; the actor then loads as its Blueprint, whose parent is the Rust class.
Blueprint-only templates
The engine’s Blueprint templates (TP_VirtualRealityBP, TP_HandheldARBP, TP_UEIntro_BP) have no C++ at all. rusteal new gives such a project a game module (Source/<Project>/, its two targets, the .uproject’s Modules entry), since the Rusteal plugin and the bindings are built with the project. Template::load notices the missing Source/ and write_game_module writes it.
The port moves each Blueprint’s logic to a Rust class and keeps the Blueprint as a data-only child of it. Reading the logic comes first, because a Blueprint’s graphs are binary.
Reading a Blueprint
The editor exports any asset as text with ObjectExporterT3D, from editor Python:
task = unreal.AssetExportTask()
task.object = unreal.load_asset("/Game/XRFramework/Blueprints/BP_XRPawn")
task.filename = "/tmp/BP_XRPawn.t3d"
task.automated = True
task.exporter = unreal.ObjectExporterT3D()
unreal.Exporter.run_asset_export_task(task)
A Blueprint’s T3D holds every graph: each node, its function or variable reference, its pins’ default values and links. Walking the exec links from each event gives the logic as text, nodes in order with their data inputs inlined. The defaults a graph does not show are elsewhere: function local variables in the function entry node’s LocalVariables, member defaults in the class default object (read by name with get_editor_property), components in the simple construction script (gathered with SubobjectDataSubsystem.k2_gather_subobject_data_for_blueprint), timeline curves in the generated class’s <Name>_Template and its curve objects, also exportable as T3D.
Moving the logic
For each Blueprint, a Rust class with:
- the Blueprint’s components, under the same object names (
#[component(name = "VROrigin")]), set up in#[class_defaults]with what is not an asset (transforms, collision, motion sources); - its variables, under the same names and types: a Blueprint
realis a double (f64), unless the variable was made single precision (f32); a user enum becomes a#[uenum]; - its functions and events, as
#[ufunction]s and overrides; its event dispatchers as#[udelegate]s; the Blueprint Interfaces it implements inimplements, the interface assets staying where they are; - its timelines as state the actor’s tick plays, with the curve read from the asset;
- the assets its graphs referenced (input actions, mapping contexts, classes to spawn, textures) as properties the Blueprint child sets.
Rebasing the Blueprint
Then, in a headless editor, each Blueprint is turned into a child of its Rust class:
- Record the values of the Blueprint’s own components (from their T3D, compared with their class’s defaults, read back by Unreal name).
- Remove its graphs (
BlueprintEditorLibrary.list_graph_namesandremove_graph), its event dispatchers (remove_event_dispatcher), its now unused variables (remove_unused_variables) and its components (SubobjectDataSubsystem.delete_subobjects). - Reparent it (
reparent_blueprint) and compile. - Set the recorded values on the inherited components of the same names, and the assets on the class defaults, by their Unreal names: editor Python wraps a Rust class as its nearest engine class, so
get_editor_property("MoveAction")works wheremove_actiondoes not. - Set again the values a layout gives the widgets placed in it (
find_objectof<WidgetBlueprint>:WidgetTree.<Name>): they were stored under the Blueprint variables the reparent removed. Python wraps a#[uenum]without itsE(EIconTypeisunreal.IconType), andunreal.IconType.cast(2)makes a value from an index. - Compile and save; resave the levels that place the Blueprints.
When the template’s levels place the Blueprints, their components stay in the Blueprints. A placed actor saves its components’ own values (a text render’s text, a spline’s points, the actor’s place in its root) under the components’ names; once the Rust parent creates a component of the same name, loading the level gives the saved values to the Rust component, marks it as created by the construction script, and the construction script that follows destroys it. The Intro To Unreal level places 102 actors this way, so its Blueprints keep their components (no_components in the rebase), the Rust classes find them by name (components::named(actor, "TextRender")), and a component the Blueprint inherited from a Blueprint parent the reparent removes is added back to it under the same name (adopt_inherited, the door’s Box). A Blueprint that keeps its own DefaultSceneRoot cannot have a Rust parent with a component of that name either: the compiler refuses the second DefaultSceneRoot property.
A data table whose row structure is a Blueprint struct moves to the #[ustruct] that replaces it: export its rows with DataTableFunctionLibrary.export_data_table_to_json_string before the port, then fill_data_table_from_json_string(table, json, rust_struct), which also makes the Rust struct the table’s row structure. The JSON names the fields as the Blueprint struct did, so the Rust fields take those names (name = "BGColor").
A variable the graphs left still use (an Anim Blueprint’s AnimGraph reads the hand’s pose alphas) clashes with the Rust property of the same name when the Blueprint is reparented, and the compiler renames it (PoseAlphaGrasp_0). In a second editor session, replace_variable_references moves its references to the inherited property and remove_unused_variables deletes it. It has to be a new session: until then Python does not see the properties the reparented class gained.
Validating
The levels are played headless (UnrealEditor <project> -nullrhi -ExecCmds="py test.py"), the test driving Play In Editor from a Slate tick callback: load the map, editor_request_begin_play, then call the Rust classes’ functions and read their properties by Unreal name (pawn.call_method("SnapTurn", (True,))). A template is done when a project rusteal new creates from it passes the same test.
Rusteal changes the ports brought
| Port | Change |
|---|---|
| Vehicle | Structs reached from exported properties are exported even when they are not Blueprint types (the Chaos vehicle setups). |
| Vehicle | FName writes keep the display index the editor shows. |
| Virtual Reality | Blueprint-only engine templates. |
| Virtual Reality | #[uenum]; Unreal enums as #[ufunction] parameters and return values; struct return values for overrides (ModifyRaw). |
| Virtual Reality | #[uproperty(BindWidget)]. |
| Virtual Reality | Enum values from third-party headers (OpenXR’s blend modes), getters for properties with only a BlueprintSetter (MotionSource), and no clippy on the generated bindings. |
| Handheld AR | Classes reached from exported functions and properties are exported, without functions, even when their module exports no API (UARBasicLightEstimate, which GetCurrentLightEstimate returns). |
| Handheld AR | Arrays of structs as properties (UeArray<OwnedStruct<T>>), the struct in the array finalized before the one holding it; #[uenum]s as #[udelegate] parameters. |
| Handheld AR | #[uproperty(BindWidgetAnim)] and Transient. |
| Handheld AR | Rust bool properties and parameters are native bools: they were one-bit fields, and a false written by Python or a Blueprint left the byte’s other bits, which Rust read as true. |
| Intro To Unreal | Features: plugin templates with a [feature] table, rusteal new --feature, templates bundling features, plugin templates carrying content. |
Template directories
How a template is laid out in the repository, and what rusteal new does with it. How an engine template becomes one is in Porting an engine template, and features in Features.
A template is a directory under rusteal-cli/templates/, one directory per
variant inside it, base being the template without --variant; they are
embedded in the binary when it is built, and nothing else registers them. A
variant directory mirrors the root of the project it creates:
template.tomlnames the engine template the project starts from (engine_template), the paths of it to leave out (exclude: the C++ gameplay the template replaces, the other variants), the level to open when it is not the engine template’s own (default_map), a one-linedescriptionand thenext_stepprinted at the end;- every other file is written into the project over the engine template’s:
*.terafiles are rendered with Tera and lose the extension, the rest (Blueprints, meshes) is copied as is; {{ variable }}works in folder and file names too:Rust/{{crate_name}}/src/lib.rs.tera.
The context is the same for every template: project, crate_name,
version, glam_version and, with --runtime-path, runtime_path.
Each variant is complete on its own. A new one starts as a copy of the
closest existing one (a variant from its template’s base) and is changed
from there, never layered on top of it. Its Blueprints come from a project
where they were made and played, saved with the engine version Rusteal
targets. A variant with something to see has a screenshot in
assets/templates/, shown on the template’s page in the game developer’s handbook.
A plugin template is a directory under rusteal-cli/plugin_templates/,
written into Plugins/<Name>/ by rusteal plugin new --template <name>:
its template.toml has the description and the next_step, and its files
are rendered the same way, with plugin, crate_name, version,
glam_version and runtime_path as context. The plugin’s module, its
Build.cs and <Name>Module.cpp, come with the template: copy them from
blank (a runtime module) or editor (an editor module).
A plugin template with a [feature] table in its template.toml is a
feature: plugin is the plugin’s name in the project (its assets live under
/<plugin>/, so a feature with Content/ keeps that name), and
shared_packs lists the engine template packs it needs
(High/Characters is Templates/TemplateResources/High/Characters), copied
into the project’s Content/ without replacing what is there. A template’s
template.toml bundles features with features = [...].
Features
A feature is gameplay that is not a template of its own but that any template can take: the Intro To Unreal template’s stations (a mannequin copying the player, checkpoints, triggers and doors) are worth having in a third person game too. A feature is packaged as a Rusteal plugin, so its Rust library, its assets and its configuration stay apart from the game’s and come along with rusteal new.
A plugin template with a [feature] table
Features live with the plugin templates, in rusteal-cli/plugin_templates/<feature>/. What makes one a feature is a table in its template.toml:
description = "Mannequins that copy the player's movement, as the Intro To Unreal template's, in a Mannequin plugin."
next_step = "..."
[feature]
plugin = "Mannequin"
shared_packs = ["High/Characters"]
pluginis the plugin’s name in the project. A feature’s assets are mounted at/<plugin>/, and Blueprints and levels refer to them by that path, so a feature with aContent/directory is always created under this name:rusteal plugin newrefuses another one for it.shared_packsare engine template packs,Templates/TemplateResources/<Level>/<Pack>, the feature’s classes or assets use. They are copied into the project’sContent/<Pack>/file by file, skipping what the project already has, so a template that ships a newer copy of a pack keeps it.
templates::features() lists the plugin templates that have the table, select_feature resolves a name as loosely as the templates (Mannequin, mannequin), and listing(), printed when rusteal new gets an unknown template, ends with them.
How rusteal new adds them
NewOptions::features holds the --feature names. run_new resolves them, after the ones the template bundles (features = [...] in a template’s template.toml), before writing anything, so a wrong name stops it early; a feature named twice is added once. Once the project files and the Rusteal plugins are written, each feature goes through plugin_cmd::create_plugin, the part of rusteal plugin new that writes a plugin without building it:
- check the name (letters and digits, not Rusteal’s modules nor the project’s);
- render the plugin template into
Plugins/<plugin>/with the plugin context (plugin,crate_name,version,glam_version,runtime_pathfrom the project’s pins); - write the bindings placeholder and the C++ stubs the plugin’s module compiles against before
rusteal buildgenerates the real ones; - enable the plugin in the
.uproject; - copy the feature’s shared packs.
The build that follows is the project’s one: rusteal build finds every Rusteal plugin under Plugins/ and builds and deploys each library after the game’s. run_new prints each feature’s next_step after its own.
Validating a feature
A feature is validated in a project of a template it does not come from: rusteal new RsFeat --template third-person --feature mannequin with --runtime-path, then a headless Play In Editor test that loads the plugin’s classes by their /Script/<Plugin>. path, places what it needs in the editor world before Play (Python cannot spawn into the PIE world), and drives the player (add_movement_input every tick: a test step that waits a tick between inputs lets the character brake to a stop).
Features made from a template’s Blueprints
The Intro To Unreal features come from that template’s Blueprints, which the intro template keeps where the engine has them (/Game/DemoTemplate/...), reparented to the features’ Rust classes so the template’s level works unchanged. A feature used in another template needs the same Blueprints and what they use (materials, meshes, widget layouts, Niagara systems, MetaSounds) inside its plugin, mounted at /<Plugin>/. They are produced from the rebased intro project, one feature at a time:
- Copy the rebased project, without its levels and
__ExternalActors__(nothing there is needed, and moving assets would rewrite every placed actor that uses them). - In a headless editor, collect the feature’s Blueprints and, through the asset registry, every package under
/Game/DemoTemplate/they depend on. - Move them all at once with
AssetTools.rename_assetsto the same relative paths under/<Plugin>/, which rewrites their references to each other, then save the plugin’s content. - Check that no moved asset still depends on
/Game/DemoTemplate/, and copyPlugins/<Plugin>/Contentinto the feature’s plugin template.
The move runs in the intro project itself, on its rebased content with the levels and external actors deleted, and the content is put back afterwards: a copy of the project at another path loads the Rusteal module twice and stops. The editor refuses renames while the asset registry is still scanning, so the script waits for it, and a plugin without a Content folder is not mounted, so an empty one is created first.
Each feature starts again from the rebased project, so an asset two features use (a material, a widget) is moved into both and neither plugin depends on the other. The shared engine packs (Characters, LevelPrototyping) stay where the feature’s shared_packs put them, under /Game/.
Setting up a checkout
This chapter is for changing Rusteal itself: its crates, the rusteal CLI and the UE plugins in ue_plugin/. To make a game, none of it is needed; see the game developer’s handbook.
Prerequisites
Everything in the game developer’s Prerequisites, plus:
| Dependency | Where from | What for |
|---|---|---|
| Git | the distribution’s package (git); on Windows, Git for Windows | cloning the repository |
| clangd (optional) | the distribution’s package or LLVM; Zed downloads it on its own | C++ support in the editor, for ue_plugin/ |
| .NET 10 SDK (optional) | Microsoft, or the distribution’s package (dotnet-sdk-10.0) | C# support in the editor, for the exporter in ue_plugin/RustealGenerator/ |
The checkout builds with the MSRV, Rust 1.88.0, whatever your default
toolchain is: rust-toolchain.toml pins it, and rustup installs it on the
first cargo command. The file names no host, so rustup completes it with its
default host; on Windows that must be x86_64-pc-windows-msvc (see the
Windows prerequisites above).
The development environment
The settings that depend on the machine live in .env at the repository root,
which is not versioned; .env.example lists them:
| Variable | What it is |
|---|---|
RUSTEAL_DEV_ENGINE_ROOT | The Unreal Engine root, the directory holding Engine/ |
Linux:
git clone https://github.com/viniciusmorgado/rusteal.git
cd rusteal
cp .env.example .env # then fill it in
cargo xtask dev-setup
Windows (PowerShell):
git clone https://github.com/viniciusmorgado/rusteal.git
cd rusteal
Copy-Item .env.example .env # then fill it in
cargo xtask dev-setup
Rusteal is developed against a UE project: the plugins in ue_plugin/ are built
inside one, and the C++ editor support reads what that build generates (UHT’s
*.generated.h headers and UBT’s compile commands). dev-setup asks which one:
- An existing project. It must have this checkout’s plugins installed and
built (
cargo run -p rusteal -- setup <project>, thenbuild);dev-setuponly reads it, never changes it, and stops with those commands if they are missing. Plugin headers that differ from the checkout’s are reported. - A new blank project, created at the path you give with
rusteal new --runtime-pathon this checkout, and built. The last component of the path is the project name.
To skip the question, pass the answer: cargo xtask dev-setup --project <path>
or cargo xtask dev-setup --new <path>.
Then it writes:
compile_commands.jsonat the repository root, for clangd: the compile commands UBT gives for that project, pointed atue_plugin/;- the exporter’s
.csproj.props, so C# language servers resolve the engine’sEpicGames.*assemblies, and adotnet restoreofrusteal.slnwith the .NET SDK bundled with the engine. The solution has two projects, both for IDEs only: the exporter (ue_plugin/RustealGenerator/Source/RustealExporter/), andide/RustealRules/, which gives the plugins’*.Build.csfiles the context UBT compiles them with. Without that project a language server analyzes those files with no references at all: nothing resolves, and everyusingis reported as unnecessary. UBT compiles theBuild.csfiles without implicit usings, so ausing System.IO;there is required, and the project makes the editor agree.
Run it again after adding a source file to the plugin, or after changing a
header that declares a UCLASS or USTRUCT and rebuilding it in the project:
a generated header only matches the header it was made from.
.env is for working on Rusteal only:
| Working on Rusteal | Making a game | |
|---|---|---|
| Engine path from | .env | the rusteal CLI, which asks once and keeps it in ~/.config/rusteal/config.toml (the platform’s config directory elsewhere) |
| Read by | cargo xtask only | the rusteal CLI |
Writes the exporter’s .csproj.props | cargo xtask dev-setup, in this checkout | rusteal setup, in the game project |
The rusteal CLI never reads .env, so it does not change which engine a game
builds with.
Testing a change
A change is done when the workspace checks pass, a project made from the checkout builds and plays with it, and the templates it can affect still do.
The workspace builds, tests and lints without the engine:
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets --no-deps -- -D warnings
That is what CI runs on every pull request; run it before pushing. Where the engine-free tests are, and what each protects, is in The CLI, Code generation and The runtime.
Anything that touches the engine is tested in a UE project of your choice,
driven by this checkout’s CLI (cargo run -p rusteal --, from the repository
root). A new project that depends on this checkout:
cargo run -p rusteal -- new Probe --dir <dir> --runtime-path .
Put it in a directory of your own, such as the one that holds your Unreal projects, not in /tmp: on Linux, UBT fails at step 1 for a project under /tmp, building the exporter’s path without its leading / (“is not an absolute path”).
After changing the runtime, the macros or a game crate:
cargo run -p rusteal -- build <dir>/Probe
After changing the UE plugins in ue_plugin/, reinstall them into the project
and build it:
cargo run -p rusteal -- setup <dir>/Probe
cargo run -p rusteal -- build <dir>/Probe --all
In the editor, each build reloads the library by itself, changed classes
included (see Hot reload); a change to the UE plugins needs an
editor 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, setup 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 project you test with on the next build,
with nothing to publish in between.
Testing in the editor, headless
What happens in the editor is tested with editor Python, in an editor with no window:
"<engine>/Engine/Binaries/Linux/UnrealEditor" "<dir>/Probe/Probe.uproject" \
-nullrhi -unattended -nosplash -nosound -NoLiveCoding \
-ExecCmds="py <dir>/test.py" -abslog="<dir>/test.log"
(UnrealEditor-Cmd.exe under Engine/Binaries/Win64/ on Windows.) A test script:
- loads a map, places what it needs in the editor world (Python cannot spawn into the Play In Editor world), and saves;
- starts Play In Editor with
LevelEditorSubsystem.editor_request_begin_play()and drives it from a Slate tick callback (unreal.register_slate_post_tick_callback), a generator that yields the number of ticks to wait between steps; - calls the Rust classes’ functions and reads their properties by their Unreal names (
actor.call_method("SnapTurn", (True,)),get_editor_property("MoveSpeed")), since editor Python wraps a Rust class as its nearest engine class; - writes
PASS/FAILlines to a results file, ends Play, and quits withunreal.SystemLibrary.quit_editor().
A C++ helper library in the test project’s own module, never in Rusteal, reaches what Python cannot: the Message Log’s entries and tokens, Blueprint graph nodes, function calls on a token’s link. The ports in Porting an engine template are validated this way.
Before a pull request
-
A game-facing API needs no
unsafeand no raw handle from its caller, takes and returns typed references and Rust types, and its error is aRustealResult; the templates’ sources stay free ofunsafe(game_developers_never_write_unsafe_code). -
The workspace checks above pass.
-
The test project builds with
rusteal build --alland plays with noLogRustealerror or warning. -
A change to the runtime, the macros, codegen or the plugin: the templates it reaches still build and play (
rusteal newof each, with--runtime-path). Codegen and plugin changes reach all of them. -
A change the game developer sees: the game developer’s handbook says so. A decision: Decisions.
Common tasks
Step by step, the changes that come up most. Each links to the chapter explaining the parts it touches.
Add a service to the API table
When Rust needs something from the engine that neither the bindings nor reflection give.
- Add the function pointer to its sub-table in
rusteal-ffi/src/api_table.rs, and at the same position inue_plugin/Rusteal/Source/Rusteal/Public/RustealApiTable.h. - Implement it in the matching
Private/Rusteal*ApiImpl.cppand add it at the same position of that file’s positionalG*Api = {...}initializer. Fail withRustealFail(code, "reason"). - Shared structs:
#[repr(C)], namedRusteal*, with size and offset asserts inrusteal-ffi/src/contract_tests.rsandPrivate/RustealContractTests.cpp. - Wrap it in
rusteal-corebehind a safe function (crate::ffi_dispatch::<table>_<field>), returningRustealResultthroughcheck_ffi,#[track_caller]. - Test:
cargo test --workspace, thensetupandbuild --allin the test project, and the editor.
Bind an engine module
- In every
rusteal-cli/templates/*/*/rusteal.toml.tera(and the plugin templates that need it), addPackage = { module = "...", feature = "...", plugin = "..." }to[codegen.modules], and the feature tofeatureswhere it should be on. - In the test project, the same lines in
rusteal.toml, thenrusteal build --all. - Link errors: blocklist the functions (next task).
- Helpers reflection cannot describe: a file in
rusteal-codegen/manual/, gated by#[cfg(feature = "...")], listed inmanual/mod.rs; prelude entries inrust_gen/prelude.rs. cargo test -p rusteal-codegen; a fixture update if the module should be tested without the engine (regenerate_fixture).
See Code generation.
Blocklist a function that does not link
- Note the wrapper’s symbol in the linker error:
Rusteal_<Class>_<Function>. - Add
"<Class>.<Function>"to[codegen.blocklist] functions, sorted, in every template’srusteal.toml.terathat carries the blocklist, and in the test project. rusteal build --from 2in the test project.
Add a macro attribute
- Parse it in
rusteal-macros(uclass.rs,uclass_impl.rs…), with a compile error for invalid input rather than silently ignoring it. - If it changes what the class is made of, include it in the shape (
shape.rs), or hot reload will keep a stale class. - Generate the registration calls; if the reify API lacks what it needs, add it first (first task).
- Test it in
rusteal-codegen/tests/manual_compiles.rs(uclass_types_compile) and in the editor. - Document it in the game developer’s handbook, Attributes.
Change the C++ plugin
- Edit
ue_plugin/. Format with.clang-format(includes keep their order). cargo run -p rusteal -- setup <project>andbuild --all, then restart the editor: plugin code is never hot reloaded.- After a new source file or a changed
UCLASS/USTRUCTheader, runcargo xtask dev-setupagain so clangd sees it. - The published CLI embeds a snapshot of the plugin, written by
rusteal sync-plugin; the release workflows run it, nothing to commit.
Add a CLI command or flag
- The clap definition in
rusteal-cli/src/main.rs, a module of its own for the work, the version check (check_version) if it acts on a project. - A test of the parsing in
main.rs’s tests. - The game developer’s handbook: CLI reference and the chapter about it.
Add a template or a variant
See Template directories and Porting an engine template. A variant starts as a copy of the closest existing one; a template’s page in the game developer’s handbook goes with it, and its screenshot in assets/templates/ and the handbook’s images/templates/.
Add a feature
See Features: a plugin template with a [feature] table, or a directory in rusteal-cli/project_features/ for files added to the project. Its page goes in the game developer’s handbook under Adding features.
Update to a new engine version
- Build a test project with the new engine; regenerate (
build --all). - Check the engine’s deprecation warnings against
docs/ue-deprecations.md; record new entries with their ids. - Blocklist what no longer links; fix what the exporter no longer reads.
- Run every template; update the prerequisites in the game developer’s handbook from the engine’s
Windows_SDK.jsonandLinux_SDK.json.
Release
Nothing to do by hand but the pull request: open develop → main; Prepare release computes the version, commits it to the pull request and checks everything; merge when its statuses pass. See Releases and versions.
The workspace
| 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 plugin_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.
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.rsfails if either.uplugindisagrees 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::initchecks the table’sversionagain. - Before
build,generateandsetup, the CLI compares the project’s pins and plugins with its own version (see Versions and upgrades).
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.
The pipeline
Four workflows in .github/workflows/ carry a change from a branch to crates.io:
| Workflow | Runs on | What it does |
|---|---|---|
ci.yml | pushes to develop, pull requests to develop and main | build, tests and Clippy of the workspace, and cargo audit, which fails on any dependency with a RustSec vulnerability advisory |
prepare-release.yml | every pull request to main | what the release will do after the merge, before it: the version, the tests, Clippy, the plugin snapshot and the packaging of every crate as it will be published |
release.yml | a push to main | publishes the crates in dependency order and creates the tag |
sync-develop.yml | the end of a Release run | fast-forwards develop to main, so the version commit reaches it |
Prepare release, in order:
- Anything to release? Nothing that goes into a package changed since the last tag: no release, and the job ends there.
- The next version. The published
max_versionof therustealcrate, bumped by the subjects of the commits since that version’s tag (v<max_version>; the lastv*tag when that one is missing,origin/mainwhen there is no tag at all). It never goes below the version already inCargo.toml: a release that failed after its version commit does not make the next one lower. - The version commit. Written into the pull request’s branch with the CI skip marker, so it does not start runs that would wait for approval (a commit pushed by the bot to a pull request needs one).
- The checks, on that commit:
cargo test --workspace, Clippy,cargo audit, the plugin snapshot sync, andcargo +stable package --workspace --exclude xtask, which builds every crate from its packaged sources. The pinned Rust 1.88 Cargo cannot package a workspace whose internal dependencies are not published yet; the stable one can. - The statuses. Because the version commit runs no workflow, Prepare release posts the
buildandbumpstatuses on it itself, once the checks passed.
The main branch has a ruleset (“main: release checks”) that requires build and bump, with no bypass: a release pull request merges only when Prepare release validated what will be published, so main never ends up with a version that does not publish.
Release repeats the tests, reads the version the merge brought, syncs the plugin snapshot, publishes the crates (skipping one whose version is already on crates.io, so a re-run finishes a partial release) and pushes the tag. Its own commits carry the CI skip marker too.
Never write a CI skip token in a commit message, a pull request’s title or its body, even to talk about one: GitHub reads it anywhere in the head commit’s message and runs nothing for that push or pull request. Call it “the CI skip marker” instead.
Tracking documents and conventions
Tracking documents
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.
What Rusteal still lacks to write Unreal’s Third Person template entirely in
Rust, in the order the template needs it, is mapped in
docs/third-person-gaps.md.
How the engine templates’ variants were ported, where the Rust ports differ
from the C++ and what they still work around is in
docs/template-variants.md.
What Rusteal does not do yet for plugins (Slate, details customizations,
importers, Blueprint nodes…), and what each takes, is in
docs/plugin-roadmap.md.
What Unreal Engine 6 changes for Rusteal — Verse, Scene Graph, the end of
Blueprints — and the open questions to check as Epic publishes details are in
the UE6 radar, kept on the ue6 branch (see
Future migration to UE6).
Commits
Commits are small, one per fix. Every subject starts with a Conventional Commits type, because the release reads them to pick the version (see Releases and versions): feat: or feat(scope): for a new capability (minor), fix:, docs:, refactor:, test:, perf:, build:, ci: and chore: for the rest (patch), and BREAKING CHANGE: as the subject for an incompatible change (major). A branch that brings a feature has at least one feat commit.
Known issues and sharp edges
Things that work as written but surprise. Each is a candidate for a fix; until then, know them before changing the code around them.
The boundary
- Function pointer order is not tested. The contract tests check struct sizes and offsets, not the order or signatures of the table’s function pointers. A field added on one side only, or in a different position, compiles and calls the wrong function. A field appended to a C++ sub-table but missing from its positional initializer is a null pointer.
- Func ids are not bounds-checked. The Rust side never reads
func_count; the bindings and the C++Generated/must come from the same codegen run. - The version is the only compatibility check. Changing the table without changing the version (any work between releases) is not detected: rebuild the plugin and the library together.
on_shutdownruns twice per unload: once as a callback, once fromrusteal_shutdown. It must stay idempotent.- The game thread is a convention. No API entry point asserts it;
GCurrentLibraryis not thread-local. Engine calls from a background thread are undefined; usetask::spawnandrun_on_game_thread. - Validity checks differ by entry point. Property and container functions only check for null; generated wrappers check
IsValidonly in Debug and Development. Rust callers mustchecked()first. - No
FWeakObjectPtrsize assert on the C++ side, though weak pointers are copied as 8 bytes.
Macros
- Unknown
#[uclass]and#[component]arguments are ignored, and attributes on a#[uclass]struct are dropped, where#[uproperty]and#[ufunction]reject unknown arguments. - Type ids hash the type’s name only. Two Rust classes with the same name in one library collide.
- Private fields are invisible to the garbage collector. A
UObjectRefkept in a private field does not keep its object alive. Pinnedis not reference counted. TwoPinnedof the same object share one root; dropping either unroots it.
Idiomatic gaps (decision I7)
No game code needs unsafe. What is still less idiomatic than it could be:
- Input actions and delegates are bound to a function by its name (
bind_action(&me, action, ETriggerEvent::Started, "OnInputStarted"),_add_ufunction(target, "Function")): a typo is found at run time, as in C++’sBindAction. Binding a method or a closure directly would catch it at compile time. RustealError::logtakes a level constant (LOG_WARNING) instead of alog::Level.get_class()andget_outer()still return raw handles, kept for existing code;class()andouter::<U>()are the typed ones.- A
#[uclass]struct’s generated fields (__obj,__rust_data) are public, for the macros’ own code; their names keep them out of the way, but they are not hidden from the type.
Static linking (consoles)
Each Rust library is a separate dynamic library today, and parts of the runtime depend on it; one aggregated static library, the route to consoles, breaks them (see Consoles):
entry!()exports the same four symbols from every library.- One API table per runtime instance (
rusteal-core/src/api.rs’sOnceLock). - Generated function ids are positions in each library’s own table.
- Type ids hash the short name only (above).
- Registration relies on
inventory’s link-time collection, which an archive does not guarantee to keep. - Every generated bindings crate is the package
bindings 0.1.0, so Cargo refuses a crate graph holding two of them. - The handshake compares the Rusteal version, not the bindings that were generated.
Hot reload
- Structs and enums keep their old layout until an editor restart.
- Rust-private fields start over from
Defaulton every reload. - Not tested: hot reload on Windows and macOS, a changed parent class, components added or removed (see
docs/hot-reload.md).
Tooling
- A test project under
/tmpfails at step 1 on Linux: UBT builds the exporter’s path without its leading/. Use a directory of your own. rusteal-cli/ue_plugin_embed/is a local, possibly stale snapshot; builds in the workspace ignore it (they readue_plugin/).setup.rs,global_config.rsandsync_plugin.rshave no unit tests; they are covered by making a project.
Decisions
Why Rusteal is the way it is. Each record gives the decision, the reasons, what was rejected when that is known, what it implies, and where it was made (a commit of the repository, or a file). The sources carry no rationale comments, so this chapter, the commit messages and docs/ are where the reasons live: a decision that changes how Rusteal works is added here in the same pull request (see Recording a decision).
Architecture
A1. Bindings come from the engine’s reflection
- Decision. A UHT exporter writes the engine’s reflection to JSON;
rusteal-codegengenerates the bindings crate and the C++ wrappers from it. Nothing is bound by hand exceptmanual/. - Why. Whatever Blueprints reach, Rust reaches, for the installed engine version and the project’s plugins, with no binding to maintain. It also bets well on UE6: Verse is built on the same reflection and UHT.
- Implies. What UHT never sees cannot be generated: Slate, C++ templates (
BindAction), delegates outside reflection. They needmanual/, native shims, or are out of reach (seedocs/plugin-roadmap.md). - Source. The design inherited from Uika;
docs/plugin-roadmap.md.
A2. Every engine call crosses a function pointer table; Rust links no C++
- Decision. The library calls the engine only through
RustealApiTableand the generated function table;rusteal-core/build.rsgenerates the dispatch fromrusteal-ffi/src/api_table.rs. - Why. Iterating on Rust needs no C++ build; the library can be swapped at run time, since pointing functions at a new library is the whole code swap.
- Implies. The table’s layout is the ABI, guarded by exact version equality (C1).
rusteal-coreships a copy ofapi_table.rs, because a published crate cannot read../rusteal-ffi(4867c56).
A3. Generated wrappers first, reflection as the fallback
- Decision. Generated functions call direct C++ wrappers;
DynamicCallgoes through reflection for functions defined in Blueprints and anything not bound. - Why. The direct path is fast and covers almost every call; reflection covers what codegen cannot see.
- Implies. The wrappers must compile and link: functions in non-public headers are skipped (
a650546), functions their module does not export are blocklisted (f90d0f9,01ddef7).
A4. One library for the game, one per Rusteal plugin
- Decision. The game’s code is a
cdylibthe Rusteal plugin loads. A Rusteal plugin has its own library, workspace, bindings and C++ module, which registers the library (RustealRegisterPluginLibrary); its C++ symbols are prefixed<Plugin>_(8e66cd7,5c1ac09). - Why. Gameplay systems become reusable across projects. Classes, functions and pins remember the library that made them, and every call runs with that library current.
- Implies. A plugin is runtime or editor, not both (two plugins otherwise); a Rust class cannot derive from another library’s Rust class.
A5. A plugin’s classes live in /Script/<Plugin>
- Why. Plugins from different authors cannot clash on a class name, and the engine sees them by their module (
0a7bbae).
A6. Rust classes are created at load time, and Blueprints derive from them
- Decision.
#[uclass]structs becomeURustealReifiedClasses (Blueprint-generated classes) with a stub BlueprintRS_<Class>; a Blueprint child holds the assets, as the engine’s C++ templates do. - Why. It is the engine’s own pattern for C++ templates, and it needed no new engine feature (
4118180). - Implies. Editor Python sees Rust classes by Unreal names; Rust classes had to be registered for cooked loading (
6b0727d); classes are created first and given members after, parents first.
A7. UE6 work stays on the ue6 branch
- Why. UE6 changes the foundations (Verse, Scene Graph, Blueprints deprecated); the adaptation is tracked in
docs/ue6-radar.mdthere and stays offmainuntil UE6 ships (1b0f730).
A8. A hard fork, independent from Uika
- Decision. Everything was renamed (
1c82d12); Rusteal develops on its own, unless Uika’s author wants to port improvements back.
A9. No WASM hosting
- Decision. Uika’s wasmtime embedding (
65f606e) was removed before the fork (d5841ff), with no reason recorded. Rusteal has no WASM support; do not assume it.
A10. Consoles: a static library per target, no engine fork, private platform packs
- Decision. Rust reaches a console build as a
staticlibper target, consumed by an external module and built by Rusteal’s CLI before UBT; the default route links one aggregated archive into the game’s executable. The repository keeps the platform-agnostic structure; each console’s target, flags, build rules and CI live in a private platform pack. The runtime takes its system services from the engine. - Why. The engine’s documented external-library mechanism links a prebuilt library on every platform, so no engine fork is needed; the console code cannot be public; and the engine is already ported to each console, unlike Rust’s standard library.
- Rejected. Patching UBT to compile Rust (kept for a limitation measured with the SDK); loose objects or LLVM bitcode as the artifact (dependencies missing, LLVM versions tied); one self-contained static library per Rusteal library (duplicate runtimes and symbols).
- Implies. The module contract and the static backend are built and tested on desktop first. See Consoles.
Runtime
B1. Panics are caught at the boundary
- Decision. Every entry point runs in
ffi_boundary(catch_unwind); a chained panic hook records the location; the panic is reported as a script error and the call returns a default. - Why. Unwinding into C++ is undefined behaviour, and a panic in game code must not take the editor down.
- Implies. A panicking function returns its default; spawned tasks and tick hooks are guarded the same way.
B2. Errors are modelled on std::io::Error
- Decision.
RustealErroris a boxed struct matched bykind(), with the object, the engine’s reason, the caller’s location, contexts and a source; fallible APIs are#[track_caller]. - Why. An error must say what failed, on what, why Unreal refused and where in the user’s code, and work with
?and other error types. - Rejected. The earlier enum of variants (a breaking change,
feat(core)!: variants became constructors). - Implies. A
#[ufunction]may returnRustealResult<T>, raising a Blueprint runtime error onErr; the C++ side keeps a per-thread reason (b7fc0c7,f44d529,1f8be32,336bc90). See Errors.
B3. Rust bools are native bools
- Why.
FBoolProperty’s default is a one-bit field; afalsewritten by Python orProcessEventcleared only that bit, and Rust, reading the byte, sawtrue(fe31dd3).
B4. FNames are packed and unpacked, never copied raw
- Why. In editor builds an
FNamecarries a display index; raw copies showedNoneand could save it (3c77034,eb43e93).
B5. FKey is an opaque reflected struct
- Why. The engine’s
FKeyis 24 bytes or more; an 8-byte newtype crashedTArrayoperations (f4a4b6b).
B6. Inheritance through Deref, upcasts checked at compile time
- Decision. Codegen emits each class’s own members; inherited ones resolve through the parent.
Inherits<U>checksupcast_toat compile time (51fdb28). - Rejected. Copying every inherited member into each class (
88efc84).
B7. Two tiers of object lifetime
- Decision.
UObjectRef<T>is aCopyhandle with no ownership;Pinned<T>is a GC root. - Why. No global registry or reference counting on the common path.
- Implies. Code calls
checked()before using a handle; pins are released when their library unloads (75dc959).
B8. Background work returns to the game thread; unloading waits for it
- Why. No thread may run code that is gone (
0f5f3c3).
Versions
C1. One version for everything
- Decision. The seven crates, the two
.uplugins and the FFI table share[workspace.package].version; internal dependencies are{ version, path }. - Why. One number to bump; it resolves locally in the workspace and from crates.io once published.
- Implies. Projects pin
"=x.y.z"; the plugin refuses a library of another version; the CLI checks the project before working on it; plugin libraries must match exactly (tolerating minor differences would need a stable table layout);tests/plugin_version.rsfails CI when a descriptor drifts.
C2. The integer form is derived, never a source
- Decision.
encode_version= major·1 000 000 + minor·1 000 + patch, for the.uplugin’s integerVersionand the table’su32. - Implies. Minor and patch stay below 1000; the
const fnrefuses anything else at compile time.
C3. Projects move forward whole
- Decision.
rusteal upgraderewrites the pins and replaces both plugins wholesale; mixed versions never run (d26cb48).
C4. A project’s glam follows Rusteal’s
- Decision. New projects get the CLI’s
GLAM_VERSION(rusteal-cli/src/new_cmd.rs), andrusteal upgraderewrites the project’sglamto it next to the pins (3461ef7). - Why. The generated bindings crate takes
glamfrom the project’s workspace whilerusteal-coretakes it from Rusteal’s; with two versions they are two crates whose types do not mix, and the bindings stop compiling. - Implies. Bumping
glamin the workspace means bumpingGLAM_VERSIONwith it. Projects that follow a checkout (--runtime-path) are not upgraded: change theirglamby hand.
Code generation
D1. The bindings are generated into each game project and committed there
- Why. They depend on the project’s engine version and modules; the Rusteal workspace builds, tests and lints without Unreal (
70add11). - Implies. Regeneration must be deterministic: module imports are sorted so a regeneration does not reshuffle files (
ca56d55).
D2. Generated files are written only when they change
- Why. Rewriting every file made UBT recompile every wrapper and Cargo rebuild the bindings; an unchanged full build went from minutes to seconds (
d8dd518).setupkeeps unchanged plugin files for the same reason (601d8d4). - Rejected. Wiping the output first (
cbb0ca5).
D3. Clippy is off in generated code
- Why. Hundreds of generated functions made
cargo clippyfail on every game crate (7d8b9fb).
D4. Modules are bound selectively
- Decision.
classes = [...]limits a large module (UnrealEd’s 354 classes); editor-only functions are bound only in editor libraries; Rusteal’s own modules are always bound (1f23a9d,4b043e1,29a6986).
D5. UHT’s gaps are fixed in the exporter, not blocklisted
- Decision. Enum values UHT leaves at -1 are evaluated, third-party constants looked up, structs reached by bound properties exported, protected members a Blueprint can reach exported (
b36344e,b416dfb,33d575c,f6496e6,b8d8159).
CLI
E1. The CLI is also a Cargo subcommand
- Why. An IDE’s Cargo run configuration starts
cargo rusteal watchthe same way on Linux and Windows (a3ce116). - Implies.
default-run = "rusteal": without itcargo run -p rustealwas ambiguous and broke the release (1e1c540).
E2. Platform behaviour is chosen at compile time
- Decision. The UBT and UAT scripts, the platform name, the library naming and the platform optimizations written all come from
#[cfg(target_os)]. - Why. A CLI only builds for its own platform; a Windows CLI never writes Linux settings.
E3. Per-platform engine settings are written by the CLI, between markers
- Decision.
[optimizations.<platform>.<File>.<Section>]inrusteal.toml, written intoConfig/<Platform>/<Platform><File>.inibynew,watchandpackage; the user’s keys win;--optimizations offleaves them out (916d531). - Why. Settings must reach every machine and every package without editing the engine install, which affects every project and is lost on upgrades. The default,
r.Vulkan.CpuWaitForFence=0, avoids an NVIDIA Linux driver crash. - Rejected. Writing them only where an NVIDIA GPU is detected: the file is versioned, machines would rewrite it back and forth, and the player’s GPU is unknown when packaging. See Platform optimizations.
E4. rusteal build builds the Rust libraries only
- Why. A change to Rust needs steps 4 and 5; the full pipeline only when the engine, the modules or the blocklist change (
fbe3c92).
E5. rusteal new does not run git init
- Why. Versioning is the user’s call; it writes a
.gitignoreso a latergit initpicks the right files (46bdf93).
E6. A project holds no machine paths
- Decision.
rusteal.tomlholds what varies between projects; the layout is fixed by convention; the engine path is per machine, in the CLI’s config directory (7d28dde,83bf735).
E7. .env is for contributors only
- Why. A contributor’s checkout setting must not change which engine a game builds with; the CLI never reads it (
7d4763b).
E8. Everything the CLI writes is embedded in it
- Decision.
cargo install rustealis the whole installation: plugins, templates, features. - Implies. The published crate carries a snapshot of
ue_plugin/(ue_plugin_embed/, written byrusteal sync-pluginbefore packaging), becausecargo packagecannot reach outside the crate (3d380b9,e858c73).
E9. A crate is built by its own manifest, not -p
- Why.
-p inventorycollided with theinventorydependency; a game named Glam would collide withglam(7604eb4).
E10. UBT runs with -NoHotReload
- Why. With the editor open, UBT named the binaries for a C++ hot reload and the link failed; Rusteal never hot reloads C++ (
7704dc2).
Hot reload
F1. A reload loads a numbered link or copy of the deployed library
- Why. A build can always replace the deployed file; a hard link costs nothing whatever the size; Windows locks a loaded DLL, so it copies.
- Implies. The deploy step replaces the file (
.partialthen rename), never writes into it (e8548e0).
F2. Classes are reinstanced with FReload, not Live Coding
- Rejected. Live Coding: Windows-only, and it patches UBT-built machine code, which a Cargo library is not.
- Implies. Reinstancing happens in the editor only; garbage collection is skipped during it to avoid a stall (
7081c51).
F3. Change is detected by a compile-time shape
- Why. A change to code alone keeps the class and costs nothing; only a structural change replaces it (
7081c51).
F4. Structs and enums are not hot reloaded; private fields reset
- Why.
FReloadreinstances classes; a changed struct or enum needs a restart, and Rust-private fields come back fromDefault.
F5. Blueprints that only use a replaced class are recompiled too
- Why. The engine recompiles a class’s children only; a Level Blueprint calling a removed function hit a fatal error on Play (
ff19417).
F6. rusteal watch polls
- Why. Small workspaces, the same behaviour everywhere, no dependency; the build runs as a child process so a failed build does not stop the watch (
656eaa1).
Templates
G1. Each variant is a complete template
- Decision.
templates/<template>/<variant>/,basethe default; a new variant starts as a copy of the closest one, never layered (da53d5e). - Why. Each variant is its own project, as the engine’s Blueprint templates make them.
- Implies. Files repeat across variants (every
rusteal.toml.teracarries the same blocklist and optimizations; tests keep them in agreement).
G2. Ports keep the engine’s names and move assets with redirects
- Why. The variants’ Blueprints, StateTrees, EnvQueries and levels keep working (
docs/template-variants.md).
G3. World Partition actors are redirected to /Script/Engine.Actor
- Why. An actor descriptor stores its first native class; a Rust class is not native, so a redirect to it makes the level skip the actor.
G4. Blueprint-only templates are reparented: logic to Rust, data stays
- Decision. Graphs removed; components, variables, enums and structs move to Rust under the same names; Anim Blueprints keep their AnimGraph (removing it crashes the compiler); the rebase runs headless in two passes (
459057b,c75bcd2).
G5. Features are plugins
- Why. Gameplay written once is reusable by any template, and by existing projects with
rusteal plugin new(9f44915). Project features add files instead, such as the RustRover run configurations (39aab8b).
G6. Levels that place many Blueprints keep their components in the Blueprints
- Why. A Rust component of the same name made the level discard each actor’s values; the Intro level places 102 such actors (
dacf99b).
G7. The dev and release profiles
- Decision.
dev: the game crate atopt-level = 1with full debug info, dependencies and bindings at3withline-tables-only.release: one codegen unit and thin LTO. - Why. The levels follow Bevy’s recommendations for games; full debug info made the library pass 100 MB, reloaded on every change. The bindings need their own
[profile.dev.package.bindings], since"*"does not cover workspace members.
G8. Templates are named for what they are
- Decision. No aliases (
tpsbecamethird-person); names still match loosely (da582af).
G9. The templates are the reference code
- Decision.
examples/gem_collector, the original demo, was removed in 0.7.0 (bde75b8): the templates cover the runtime API and every change keeps them building and playing.
Build and release
H1. Rust 1.88 is pinned, with no host triple
- Why. The checkout cannot use newer Rust by accident; 1.88 is the version where edition 2024’s
letchains, which the code uses, are stable. Without a triple the file works everywhere; on Windows the host must be MSVC, which the engine links with. - Implies. Packaging in the release pipeline uses
cargo +stable, because the pinned Cargo cannot package crates whose internal dependencies are not published yet.
H2. Cargo.lock is not versioned
- Why. Every build would rewrite the internal versions in it; a dependency release that breaks the workspace shows up in CI.
cargo install --lockedstill works, since the published crate packs its own lock file. - Rejected. Versioning it, tried and reverted the same day (
40f4f8a,fe7a952).
H3. The next version comes from crates.io and Conventional Commits
- Decision. The last release is the
rustealcrate’smax_versionon crates.io, the one source that cannot drift;BREAKING CHANGE:bumps major,featminor, the rest patch (56d5ef5).
H4. Commits are counted from the published version’s tag; the version never goes down
- Why. Counting only what
mainlacked lost thefeatcommits of a merge whose release had failed: 0.7.0 came out as 0.6.2 (34556c9).
H5. The version is committed on the release pull request, and checked before the merge
- Why. A release that cannot publish must fail the pull request, not leave
mainahead of crates.io; themainruleset requires thebuildandbumpstatuses Prepare release posts (8a2d092,4dd6ba3).
H6. The bot’s version commits carry the CI skip marker
- Why. Runs triggered by the bot on a pull request wait for an approval and fail when it expires (
a48f7fb). Never write the marker anywhere else, not even in a message about it.
H7. Publishing is ordered, repeatable, and skipped when nothing changed
- Decision. Crates are published in dependency order, waiting for the index; an already published crate is skipped, so a failed run can be repeated; nothing is released when no package’s content changed since the last tag (
aa55166,a2da834).
H8. develop is synced by its own fast-forward workflow
- Why. A failed sync never touches what the release did, and it can be re-run alone; it refuses to push when
develophas commitsmainlacks (aa55166).
H9. Release workflows ask for contents: write
- Why. The default token is read-only in new repositories, and pushing the tag failed (
68673dd).
H10. A vulnerable dependency fails the checks
- Decision.
cargo auditruns in CI and in Prepare release, before it reports the statusesmainrequires (7d873c2). - Why. A dependency with a RustSec vulnerability advisory must never reach a release;
Cargo.lockis not versioned (H2), so the audit checks what a build resolves that day. - Implies. Only vulnerabilities fail; unmaintained and yanked crates warn. A new advisory can turn a pull request red with no change of its own: update or replace the dependency.
cargo-auditcomes prebuilt throughtaiki-e/install-action, pinned to a commit.
Process
I1. The engine’s templates set the priorities
- Decision. A feature earns its place because a template, or a real project, needs it (
docs/third-person-gaps.md; see the Roadmap).
I2. Tracking documents use stable ids
- Decision.
TP-GAP-NNandUE-DEP-NNNare never renumbered; fixed entries keep their number.
I3. Small commits, Conventional Commits subjects, no rationale comments in code
- Decision. One commit per fix; the subject’s type drives the version (H3); the sources carry no comments, Rust follows rustfmt and C++
.clang-format. The reasons go in commit bodies,docs/and this chapter.
I4. IDE-only projects are versioned but never shipped
- Decision.
rusteal.slnandide/RustealRulesgive the*.Build.csfiles the context UBT gives them; without them a language server flags requiredusinglines as unnecessary, and removing them breaks UBT (f0e9d1d).
I5. Verified headless, then by hand
- Decision. Ports and hot reload are validated in a headless editor driven by Python, then by hand; the docs record what was tested and what was not (Windows and macOS hot reload, changed parent classes).
I7. A game never needs unsafe, and is written in idiomatic Rust
- Decision. Two rules for every feature Rusteal adds: a game developer using it never has to write
unsafecode, and the whole game can always be written in idiomatic Rust. Every game and plugin crate the templates create starts with#![deny(unsafe_code)]. - Why. Rusteal’s promise to a game developer is Rust’s: memory safety checked by the compiler, and code that reads like any other Rust. An API that hands out raw handles, or needs
unsafeto be used, moves the risk onto the game, where nobody audits it. - Implies.
unsafelives in Rusteal (rusteal-core, the generated bindings, the macros’ output) behind safe functions; a raw handle from the engine becomes a typed reference through a checked call (UObjectRef::from_handle,SubclassOf::from_class), never throughfrom_rawin game code.- Game-facing APIs take and return typed references and Rust types (
SpawnParamswithOption<UObjectRef<Actor>>,CommandContext::world(),load_class::<T>()), notUObjectHandles, and log through thelogcrate. - The macros’ output is accepted by
#, andgame_developers_never_write_unsafe_code(rusteal-cli/src/templates.rs) fails when a template’s code usesunsafe,from_raw, raw handles orulog!. - A feature that cannot meet the rules is not finished: either the safe API comes with it, or the gap is recorded in Known issues.
I6. The README presents, the handbooks document
- Decision. The README keeps the presentation (what Rusteal is, why use it, UE6, acknowledgments, license) and points to the two handbooks; using Rusteal is documented in the game developer’s handbook, working on it in this one.
- Why. Nobody reads a 1600-line README; each handbook serves one reader.
Recording a decision
Add a record here when a change:
- picks one approach among others a reader might expect (the rejected one goes in the record);
- adds a constraint others must keep (an ABI rule, a layout, an order);
- works around an engine or toolchain behaviour;
- changes the process (branches, releases, conventions).
Write it in the area it belongs to, with the next number, in the same pull request as the change, and name the commit or file it comes from.
Roadmap
Unreal is a huge engine, and it is constantly changing, as the UE6 radar shows. Because of this, we prioritize the bindings and features Rusteal adds by what real projects need, and by what parity with C++ basic functionality requires.
That is why the first features Rusteal brought bindings for were those the engine’s own templates use, the ones Unreal offers when a new game is created. Epic already delivers the base boilerplate for a number of genres as part of the engine, and most projects start from it; if Rusteal wants to give its bindings the same experience, the templates have to be ported first. They are the Rusteal templates, every one of the engine’s game templates with its gameplay in Rust.
Going forward
Right now, the main guide to Rusteal’s development is Noir Blue, a first person shooter that the developers of Rusteal itself are making with it: what the game needs and Rusteal lacks is what comes next.
Consoles are planned in two phases: what can be built on desktop and in public first (the module contract, a static backend, target platforms in the CLI, the runtime’s services taken from the engine), then the bring-up with a console’s SDK. See Consoles.
What Rusteal does not cover yet, and what each gap takes, is tracked in the repository’s docs/: the third person gaps, the plugin roadmap, and, for platforms, mobile and console support, both being worked on (see Unreal Engine and Rust for the console path).