Skip to main content

Welcome to a Grasp lesson

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

Lesson 16

Unity-Rust FFI: Character Data Validation

Recommended resources

Hello! Welcome back to our project.

In the last lesson, you built a fantastic character creation UI in Unity. Your CharacterCreatorController script now successfully gathers all the necessary dataβ€”name, age, personality scores, and traitsβ€”and prints it to the console. The frontend is ready.

Meanwhile, our Rust backend has the Character, Personality, and AgeStage data structures waiting to be populated. The bridge between them, the Foreign Function Interface (FFI), is in place, but so far we've only used it for simple calls.

Today, we'll connect the two sides in a more meaningful way. This lesson focuses on achieving the learning outcome: Pass character creation data from Unity to Rust via the FFI for validation. We will package the data from your UI, send it across the FFI boundary, have Rust check it, and receive a response. This is a critical step in making our Rust engine and Unity frontend work together as a cohesive whole.

1. The FFI Data-Passing Challenge

So, why can't we just pass the C# data directly to a Rust function? The problem is that different languages and compilers organize data in memory in their own unique ways. A C# class or struct has a memory layout that the Rust compiler knows nothing about, and vice versa. Sending data naively would be like trying to fit a puzzle piece from one puzzle into a completely different oneβ€”it won't work and will likely crash the application.

Rust-C FFI Compilation and Linking Process
This flowchart illustrates the FFI process. Our Rust code (`lib.rs`) and C# code are compiled separately. To communicate, they must agree on a common interface, which is what we are building today. The C ABI (Application Binary Interface) serves as this common ground.

The solution is to instruct both the C# and Rust compilers to lay out our data structure in memory according to a shared, universally understood standard: the C language's memory layout.

To understand this concept better, let's dive into a short but crucial article.

Guide to zero-copy FFI with Rust and Unity

This article from Test Double, "Guide to zero-copy FFI with Rust and Unity", explains exactly why we need to be explicit about memory layout and introduces the [StructLayout] and #[repr(C)] attributes we'll be using.

Please read the sections titled 'Compilers, Languages, and Memory', 'C-style in C#: LayoutKind.Sequential', and 'C-style in Rust: repr(C)'. This will provide the theoretical foundation for the code we are about to write.


2. Defining the FFI Data Structure

Now, let's apply those concepts. We will create two versions of a data structureβ€”one in Rust and one in C#β€”that are guaranteed to have the same memory layout. This structure will be a simplified, FFI-safe representation of our character data.

On the Rust Side

In your Rust project's src/lib.rs file, let's define the struct that will receive the data. Strings and other variable-sized types are tricky across FFI boundaries. The standard way to pass a string from C# to Rust is as a pointer to a C-style, null-terminated string, which in Rust is represented as *const c_char. For the list of traits, we'll adopt a simple strategy: combine them into a single comma-separated string in C# and pass that over.

Add the following code to src/lib.rs:

// Add these 'use' statements at the top of your file
use std::ffi::CStr;
use std::os::raw::c_char;

// This struct defines the memory layout we expect to receive from Unity.
// #[repr(C)] ensures it's compatible with C's memory layout.



#[repr(C)]
pub struct CharacterFfiData {
    pub name: *const c_char,
    pub age_stage: *const c_char,
    
    // HEXACO personality values
    pub honesty: f32,
    pub emotionality: f32,
    pub extraversion: f32,
    pub agreeableness: f32,
    pub conscientiousness: f32,
    pub openness: f32,
    
    // A single string with traits, e.g., "Creative,Ambitious"
    pub traits: *const c_char,
}

Notice we are using f32 which corresponds to float in C#, and *const c_char which will correspond to a C# string. The order of these fields is critical and must be identical on the C# side.

On the C# Side

Now, let's define the matching structure in Unity. You can add this struct definition inside your CharacterCreatorController.cs script, or in a separate file for better organization (e.g., FfiData.cs).

using System.Runtime.InteropServices;

// This struct mirrors the Rust CharacterFfiData struct.
// [StructLayout(LayoutKind.Sequential)] ensures it has a C-compatible memory layout.
[System.Serializable]
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct CharacterFfiData
{
    public string name;
    public string age_stage;

    // HEXACO
    public float honesty;
    public float emotionality;
    public float extraversion;
    public float agreeableness;
    public float conscientiousness;
    public float openness;

    public string traits;
}

Here, LayoutKind.Sequential tells the C# compiler to lay out the fields in memory in the order they are declared. CharSet = CharSet.Ansi specifies that the string fields should be marshalled (converted) into null-terminated C-style strings, which is exactly what our Rust *const c_char expects.


3. Creating the Rust Validation Function

With our shared data structure defined, we can now create the FFI function in Rust that will receive it. This function will take a pointer to the CharacterFfiData, read the data safely, perform a simple validation, and return a boolean indicating success or failure.

Add the following function to your src/lib.rs:




#[no_mangle]
pub extern "C" fn validate_character_data(data_ptr: *const CharacterFfiData) -> bool {
    // FFI calls are inherently unsafe because we trust the caller (Unity)
    // to provide valid data and pointers.
    unsafe {
        // 1. Safety Check: Ensure the pointer is not null.
        if data_ptr.is_null() {
            println!("[RUST] Received a null pointer.");
            return false;
        }

        // 2. Dereference the pointer to get a reference to our data.
        let data = &*data_ptr;

        // 3. Convert C-style strings (*const c_char) to Rust strings (&str).
        // This is also unsafe because we must ensure the pointer is valid.
        let name = CStr::from_ptr(data.name).to_str().unwrap_or_default();
        let age_stage = CStr::from_ptr(data.age_stage).to_str().unwrap_or_default();
        let traits_str = CStr::from_ptr(data.traits).to_str().unwrap_or_default();
        
        println!("[RUST] --- Received Character Data ---");
        println!("[RUST] Name: {}", name);
        println!("[RUST] Age Stage: {}", age_stage);
        println!("[RUST] Personality: H:{}, E:{}, X:{}, A:{}, C:{}, O:{}",
            data.honesty, data.emotionality, data.extraversion, 
            data.agreeableness, data.conscientiousness, data.openness);
        println!("[RUST] Traits: {}", traits_str);
        
        // 4. Perform Validation
        // For now, a very simple check: the name must not be empty.
        if name.trim().is_empty() {
            println!("[RUST] Validation FAILED: Name is empty.");
            return false;
        }

        println!("[RUST] Validation PASSED.");
        true
    }
}

After adding this code, rebuild your Rust library using cargo build --release and copy the resulting .dll (or .so/.dylib) file back into your Unity project's Assets/Plugins folder.

This video clip demonstrates the pointer safety check we just implemented (is_null), which is a crucial practice in FFI programming.

Using C Libraries in Rust

This video from Caleb Curry, while about Rust calling C, effectively demonstrates the importance of checking pointers for null before using them to prevent crashes.

Watch from 17:15 to 18:05. Notice how an if check is used to verify the pointer is not null before attempting to dereference it. We've used this exact pattern in our Rust validation function.


4. Calling the Validation Function from Unity

Now for the final step: updating the CharacterCreatorController in Unity to populate our new CharacterFfiData struct and call the Rust function.

First, you need to declare the external Rust function using DllImport. Make sure to replace "your_lib_name" with the actual name of your Rust library file (without the extension).

Add this declaration inside your CharacterCreatorController class:

// Replace "rust_life_sim" with the name of your Rust library
private const string RustLib = "rust_life_sim"; 

[DllImport(RustLib)]
private static extern bool validate_character_data(ref CharacterFfiData data);

We use ref CharacterFfiData to tell C# to pass a pointer to our struct, which is exactly what the Rust function expects.

Next, modify the OnCreateCharacterClicked method. Instead of just logging to the console, it will now populate the struct and make the FFI call.

private void OnCreateCharacterClicked()
{
    // 1. Create an instance of our FFI-safe struct
    CharacterFfiData ffiData = new CharacterFfiData();

    // 2. Populate the struct from the UI elements
    ffiData.name = characterName.value;
    ffiData.age_stage = ageStage.value;

    ffiData.honesty = honestySlider.value;
    ffiData.emotionality = emotionalitySlider.value;
    ffiData.extraversion = extraversionSlider.value;
    ffiData.agreeableness = agreeablenessSlider.value;
    ffiData.conscientiousness = conscientiousnessSlider.value;
    ffiData.openness = opennessSlider.value;

    // Join selected traits into a single comma-separated string
    List<string> selectedTraits = new List<string>();
    if (creativeTrait.value) selectedTraits.Add("Creative");
    if (ambitiousTrait.value) selectedTraits.Add("Ambitious");
    if (bookwormTrait.value) selectedTraits.Add("Bookworm");
    ffiData.traits = string.Join(",", selectedTraits);

    Debug.Log("[UNITY] Sending character data to Rust for validation...");

    // 3. Call the Rust function
    bool isValid = validate_character_data(ref ffiData);

    // 4. Log the result returned from Rust
    if (isValid)
    {
        Debug.Log("[UNITY] Rust has validated the character data successfully!");
    }
    else
    {
        Debug.LogWarning("[UNITY] Rust reported the character data is invalid.");
    }
}

Now, run your Unity scene. Fill out the character creator form and click the "Create Character" button. Check the Unity console. You should see the log from Unity sending the data, followed by a series of logs from Rust confirming it received the data, and finally a message from Unity reporting the validation result. Try creating a character with an empty name to see the validation fail!


Conclusion

Congratulations! You have successfully passed a complex, structured set of data from your Unity C# code to your Rust backend. You've learned one of the most important and fundamental patterns in FFI programming.

Key Takeaways:

  • To pass complex data across the FFI, both languages must agree on a memory layout, typically the C ABI.
  • In Rust, you use #[repr(C)] on a struct to enforce C-style memory layout.
  • In C#, you use [StructLayout(LayoutKind.Sequential)] on a struct.
  • Variable-sized types like strings are passed as pointers (e.g., C#'s string becomes Rust's *const c_char).
  • Working with raw pointers in Rust requires unsafe blocks and careful checks (like is_null()) to prevent crashes.
  • The CStr type in Rust is the standard way to safely handle C-style strings received from an FFI call.

You have now built a complete data pipeline from the user's input in the UI, across the FFI, and into your game engine's logic.

In our next lesson, we will complete the character creation loop. When the data is validated successfully by Rust, we will take the final step: Save a validated character profile to a dedicated JSON file upon creation.