Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 8

FFI Data Transfer: Rust & Unity

Recommended resources

Hello! Welcome back to your course on building a life simulation game.

In our last lesson, we successfully established a one-way communication channel, calling a simple Rust function from C# and receiving an integer back. This was a critical first step, but a real game engine needs to exchange much more complex information.

Today, we will address the learning outcome: Pass structured data (strings, numbers, and booleans) between Rust and Unity across the FFI boundary. We'll move beyond single numbers and learn how to bundle different data types into structs, send them from Unity to Rust, and tackle the crucial challenge of safely passing strings back and forth. This will enable us to send configuration data, character information, and eventually, the serialized JSON data that is core to our game's architecture.

1. The Memory Layout Contract

So, why can't we just define a class in C# and a struct in Rust and expect them to work together? The reason is that compilers for different languages are free to organize data in memory however they see fit for optimization. A C# object's memory layout is not guaranteed to match a Rust struct's layout, even if they have the same fields.

To solve this, we must explicitly tell both compilers to use a standardized, predictable memory layout. The de facto standard for this is the C language's memory layout.

Guide to zero-copy FFI with Rust and Unity - Test Double

The article 'Guide to zero-copy FFI with Rust and Unity' from Test Double explains this concept clearly. Please read the following short sections to understand why we need to be explicit about memory layout and how to achieve it in both C# and Rust.

Read the sections titled 'Compilers, Languages, and Memory', 'C-style in C#: LayoutKind.Sequential', and 'C-style in Rust: repr(C)'. This will give you the foundational theory for the practical steps that follow.

As the article explains, we enforce this C-style memory contract using:

  • #[repr(C)] in Rust.
  • [StructLayout(LayoutKind.Sequential)] in C#.

With this in mind, let's build something practical.


2. Passing a Struct from C# to Rust

Let's imagine we want to pass a simple set of graphics settings from our Unity frontend to the Rust engine. This structure will contain a boolean and a floating-point number.

Step 1: Define the Struct in Rust

In your Rust project's src/lib.rs, add the following code.

// A struct with a C-compatible memory layout.



#[repr(C)]
pub struct GraphicsSettings {
    pub is_fullscreen: bool,
    pub render_scale: f32,
}

// An FFI function that receives the struct by value.



#[no_mangle]
pub extern "C" fn apply_graphics_settings(settings: GraphicsSettings) {
    // In a real engine, you'd apply these settings.
    // For now, we'll just print them to the console to confirm they were received.
    println!(
        "[Rust] Received graphics settings: fullscreen={}, scale={}",
        settings.is_fullscreen, settings.render_scale
    );
}

Notice that the GraphicsSettings struct is marked #[repr(C)]. The function apply_graphics_settings takes this struct by value. For simple structs like this, the FFI boundary can copy the data directly.

Step 2: Define the Matching Struct in C#

Now, in your Unity project, create a new C# script named FFIStructs.cs in the Assets folder. We will use this file to hold the C# definitions of our shared structs. This keeps our FFI-specific data structures organized and separate from MonoBehaviour scripts.

using System.Runtime.InteropServices;

// This struct must have the same fields in the same order as the Rust struct.
[StructLayout(LayoutKind.Sequential)]
public struct GraphicsSettings
{
    public bool isFullscreen;
    public float renderScale;
}

Here, [StructLayout(LayoutKind.Sequential)] ensures that C# lays out the fields in memory in the declared order, just like C would, making it compatible with our #[repr(C)] struct in Rust.

Step 3: Update the Bridge and Call the Function

First, let's add the new function to our RustBridge.cs script.

// In RustBridge.cs
[DllImport(RustLib, EntryPoint = "apply_graphics_settings")]
public static extern void ApplyGraphicsSettings(GraphicsSettings settings);

Next, let's call this function from our GameManager.cs.

// In GameManager.cs
void Start()
{
    // ... your existing code ...

    // Create an instance of our settings struct and populate it.
    var settings = new GraphicsSettings
    {
        isFullscreen = false,
        renderScale = 0.85f
    };

    // Pass the struct to our Rust function.
    RustBridge.ApplyGraphicsSettings(settings);
    Debug.Log("Sent graphics settings to Rust.");
}

Finally, recompile your Rust library (cargo build), copy the new .dll over to Assets/Plugins, and restart the Unity Editor.

Press Play in Unity. In your Unity Console, you will see the message "Sent graphics settings to Rust." But more importantly, if you check the console where you ran the Unity Editor from (or the editor log file), you should see the message from Rust:

[Rust] Received graphics settings: fullscreen=false, scale=0.85

You've successfully passed a structured object containing a boolean and a number across the FFI boundary!


3. The Challenge of Strings

Passing strings is more complex because they aren't fixed-size types. A string is essentially a pointer to a sequence of characters on the heap. Both Rust's String and C#'s string are managed types, and we cannot pass them directly. Instead, we fall back to the C-style representation: a pointer to a null-terminated array of characters.

Passing a String from C# to Rust

This direction is surprisingly straightforward because the .NET marshaller does most of the heavy lifting for us.

In lib.rs:

use std::ffi::CStr;
use std::os::raw::c_char;




#[no_mangle]
pub extern "C" fn log_message_from_unity(message: *const c_char) {
    // Unsafe block is required to dereference the raw pointer.
    let c_str = unsafe {
        // We are trusting that C# sent a valid, null-terminated C string.
        assert!(!message.is_null());
        CStr::from_ptr(message)
    };

    // Convert the CStr to a Rust string slice.
    let rust_str = c_str.to_str().unwrap();
    println!("[Rust] Log from Unity: {}", rust_str);
}
  • We take a *const c_char, which is a raw pointer to a character.
  • In an unsafe block, we use CStr::from_ptr to safely wrap this raw pointer. This function reads memory until it finds a null byte (\0), creating a slice that represents the C string. It doesn't copy the data.
  • c_str.to_str() then attempts to convert this UTF-8 byte slice into a Rust &str, which will only fail if the data from C# was not valid UTF-8.

In RustBridge.cs:
The C# side is very simple.

// The 'string' type is automatically marshalled to a C-style string pointer.
[DllImport(RustLib, EntryPoint = "log_message_from_unity")]
public static extern void LogMessageFromUnity(string message);

In GameManager.cs:

// Add this to your Start() method
RustBridge.LogMessageFromUnity("Hello from C#!");

Recompile, copy the library, restart Unity, and hit Play. The Rust console output will now include:

[Rust] Log from Unity: Hello from C#!

Returning a String from Rust to C#

This is the trickiest part because it involves memory allocation and ownership. Who allocates the memory for the string, and who is responsible for freeing it?

The standard pattern is:

  1. Rust allocates the memory and creates the string.
  2. Rust gives C# a raw pointer to this memory, transferring ownership.
  3. C# reads the data from the pointer into a managed string.
  4. C# calls a second Rust function to tell Rust to free the memory it allocated.

Let's implement this.

In lib.rs:

use std::ffi::CString;

// 1. Rust allocates and returns a pointer.



#[no_mangle]
pub extern "C" fn get_player_name() -> *mut c_char {
    let name = "John Doe";
    let c_string = CString::new(name).unwrap();
    // Convert the CString into a raw pointer, giving up ownership.
    c_string.into_raw()
}

// 2. C# calls this function to free the memory.



#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
    unsafe {
        if s.is_null() {
            return;
        }
        // Retake ownership of the pointer so Rust can deallocate it.
        let _ = CString::from_raw(s);
    }
}

In RustBridge.cs:
We need to import both functions. Note the use of IntPtr for the raw pointer.

using System; // Required for IntPtr

[DllImport(RustLib, EntryPoint = "get_player_name")]
public static extern IntPtr GetPlayerName();

[DllImport(RustLib, EntryPoint = "free_string")]
public static extern void FreeString(IntPtr stringPtr);

In GameManager.cs:
Here we tie it all together in a try...finally block to ensure the memory is always freed.

// Add this to your Start() method
IntPtr playerNamePtr = IntPtr.Zero;
try
{
    // 1. Get the pointer from Rust.
    playerNamePtr = RustBridge.GetPlayerName();
    
    // 2. Marshal the C-style string pointer into a managed C# string.
    string playerName = Marshal.PtrToStringAnsi(playerNamePtr); // Or PtrToStringUTF8
    
    Debug.Log($"Received player name from Rust: {playerName}");
}
finally
{
    // 3. Ensure we always call the free function, even if an error occurs.
    if (playerNamePtr != IntPtr.Zero)
    {
        RustBridge.FreeString(playerNamePtr);
        Debug.Log("Freed string memory allocated by Rust.");
    }
}

This create/free pattern is fundamental to safe FFI. The following video, although about generating meshes, demonstrates this exact concept of creating a "context" in Rust and later freeing it from C#.

Tutorial: Use Rust to generate meshes in Unity

Watch this section from 'Tutorial: Use Rust to generate meshes in Unity' to see the create/free pattern in action. The author creates a Context object in Rust (returning a pointer) and then defines a function to free it, which is called from C#.

Watch from 02:30 to 04:02 to see the context_new and context_free functions being defined in Rust. Then, watch from 06:32 to 07:11 to see how these are wrapped and used in C# with a Dispose pattern, which is conceptually similar to our try...finally block for resource cleanup.


Conclusion

Today you've made a significant leap in building your game's foundation. You can now pass not just simple numbers, but entire data structures between Unity and Rust. This is the mechanism we will use for everything from character creation data to game state updates.

Key Takeaways:

  • Structured data must have a C-compatible memory layout, enforced by #[repr(C)] in Rust and [StructLayout(LayoutKind.Sequential)] in C#.
  • Simple structs containing numbers and booleans can be passed by value, and the FFI marshaller handles the data copy.
  • Passing strings from C# to Rust is simplified by the .NET marshaller, but requires unsafe code and CStr::from_ptr in Rust to read correctly.
  • Returning strings (or any heap-allocated data) from Rust to C# requires a strict memory management pattern: Rust allocates and returns a raw pointer (CString::into_raw), and C# must call a corresponding free function (CString::from_raw) to prevent memory leaks.

In our next lesson, we will build upon this by designing a robust command queue for sending serialized JSON commands from Unity to Rust. Instead of creating dozens of FFI functions for every little action, we'll create a single, powerful pipeline for sending complex commands as JSON strings, a core part of our game's flexible architecture.