Rustidor #2: Porting Sauerbraten from C++ to Rust

jsrmalvarezjsrmalvarez
9 min read

How to manipulate C++ Data from Rust

In this post, we will learn how to access data that resides in C++ from Rust. Our journey will begin with reading the data, and then we'll explore how to modify it.

Before diving into the specifics, make sure you're following along with the right branch for this tutorial.

~/$RUSTIDOR_WORKING_DIR/rustidor$ git checkout post#2

The vec Problem

Let's take a look at weapon.cpp. You'll notice a bunch of references to struct vec. What is struct vec? It's a class in geom.h that holds three float values. These floats are pretty versatile – they can be x, y, z coordinates, r, g, b colors, or just an array with three elements.

struct vec is also packed with vector and matrix operations. Porting these to Rust might be a bit of a hassle, but it's nothing too tricky. The real snag is that the data in struct vec is public. The code is full of direct accesses to stuff like vec.x. If we shift this data over to Rust and switch to using getters and setters, we'd have to rewrite all those direct accesses.

Our endgame is to get the whole game running in Rust. But for now, we can keep the struct as it is and work with it from Rust.

Preparing Access to C++ Data from Rust

Alright, so we're keeping the data in the C++ side of things. What we'll do next is whip up some Rust functions that can fiddle with this data and mimic the methods of the vec struct.

First things first, create a new file named vec.rs. In this file, we're going to create something similar to the C++ struct vec. Let's call it CVec. This CVec struct will be able to hold three values of type T. Why make it generic? You'll see the benefits later. Also, we need to let Rust know that it should store these values the same way C does. We do this by adding #[repr(C)] right before the struct definition.

Here's what vec.rs should look like:

#[repr(C)]
pub struct CVec<T> {
    pub x: T,
    pub y: T,
    pub z: T,
}

Reading struct vec's Data: Porting of rst_iszero

Now let's get our hands on porting the vec::iszero method. This method is pretty straightforward – it checks if the x, y, and z values are all zero, and if they are, it returns true.

Here's how it looks in geom.h:

bool iszero() const { return x==0 && y==0 && z==0; }

We're going to switch this out with a call to a new function that does the same thing. This new function will take a vec and check its values.

So, in geom.h, we now have:

bool iszero() const { return rstd_iszero(this); }

And of course, we need to declare this new function. In rust_port.h, add:

bool  rstd_iszero(const void* v);

This way, we're setting up a bridge between our C++ and Rust code, keeping the functionality intact while we shift gears.

But here's the cool part: the implementation of this function will actually be in Rust. Just like we did in the previous article, we'll let the Rust compiler know a couple of things about our function. First, we tell it not to mangle the name, which means keeping the function name as it is. This is important for C++ to recognize it. Second, we use the C calling convention to ensure that our Rust function can be called from the C++ side without any hiccups.

Imports from cty Crate

Alright, let's talk about how our function will interact with the struct vec:

  • First off, our function will be taking a pointer to struct vec. In C++, this is a void*, but in Rust, we map it to a cty::c_void pointer. This means we're going to need the cty crate for its c_void type.
  • Since our comparison isn't with a float value like 0.0 but with an integer 0, we'll need to treat our CVec struct as if it contained c_int values. So, we'll also bring in the c_int type from the cty crate.

Here's a quick look at what our imports will look like:

use cty::{c_void, c_int};

Implementation of rst_iszero

Now, let's dive into the rst_iszero function. What we need to do here is convert the c_void pointer into a pointer to CVec<c_int>. To peek inside and see what's going on, we'll have to dereference this pointer. Here's the catch: dereferencing a pointer in Rust is considered an unsafe operation, because there's no absolute certainty that the pointer is pointing to the right place. But for now, let's go with it and trust that it's all good.

Here's a look at how we'd implement this:

File vec.rs:

extern crate cty;
use cty::{c_int, c_void};

#[repr(C)]
pub struct CVec<T> {
    pub x : T,
    pub y : T,
    pub z : T,
}

#[no_mangle]
pub extern "C" fn rstd_iszero(vec: *const c_void) -> bool {
    // This is 3 steps:
    // 1. convert vec to a const pointer to CVec holding c_int,
    // 2. dereference it (unsafe)
    // 3. convert it to inmutable reference.
    let vec = unsafe { & *(vec as *const CVec<c_int>) }; 

    // Check for zero values
    vec.x == 0 && vec.y == 0 && vec.z == 0   
}

Let's touch on an important aspect of our rst_iszero function. Remember, our aim here is just to look at the vec data, not to change it. That's why in C++, the rstd_iszero function takes a const*. This tells us, and anyone else reading the code, that we're only reading from this data, not writing to it.

On the Rust side, we follow this principle by converting the pointer to an immutable reference inside rst_iszero. This step is more than just a formality; it's about sticking to Rust's safety norms and respecting the data's constancy, just like we promised in the C++ declaration.

Writing struct vec's Data: Porting of mul(float f)

Now, let's shift gears a bit and look at porting a function that actually modifies struct vec's data. We'll focus on the vec::mul(float f) function. This function multiplies each component of the vector by a float value. Here's how it's implemented in C++:

In geom.h:

vec &mul(float f) { x *= f; y *= f; z *= f; return *this; }

This function takes a float, multiplies the x, y, and z values of our vector by this float, and then returns the modified vector. Next up, we'll see how to bring this functionality over to Rust, keeping in mind that we're now dealing with modifying data, not just reading it.

As we've seen, the vec::mul(float f) function is all about scaling the vector. It multiplies each component (x, y, z) by a scalar float value. Pretty straightforward, right?

Just like we did with rust_iszero, we're going to swap out the C++ implementation with a call to a Rust function. This time it's a bit trickier. We need to handle a pointer to the current instance of our vector, perform the data manipulation in Rust, and then return a pointer to the modified instance.

Here's the change in geom.h:

vec &mul(float f) { vec* v = static_cast<vec*>(rstd_mul(this, f)); return *v; }

This approach, where we rely on the rstd_mul function in Rust, comes with a bit of a safety concern. We're essentially trusting that rstd_mul will return a pointer that matches the this pointer. It's a bit of a leap of faith, since we're not verifying this within the function itself.

An alternative would be to modify the data directly within rstd_mul and notreturn anything, and leave the return *this but, let's stick to the pattern. We can always change it later.

The declaration for rstd_mul is:

File rust_port.h:

void* rstd_mul(void* v, const float f);

And the rust implementation:

File vec.rs:

#[no_mangle]
pub extern "C" fn rstd_mul(vec: *mut c_void, f:c_float) -> *mut c_void {
    let vec = unsafe { &mut *(vec as *mut CVec<c_float>) };
    vec.x *= f; vec.y *= f; vec.z *= f;    
    vec as *mut CVec<c_float> as *mut c_void
}

It's crucial to remember that we're altering the data in vec, so we need to use mutable pointers. In Rust, this means when we convert the pointer to a reference, it has to be a mutable reference to allow for data modification.

Also, in this context, our CVec is going to handle c_float types. So, let's update our use declaration in vec.rs accordingly:

extern crate cty;
use cty::{c_int, c_float, c_void};

Bringing the vec Module into the Library

To make sure our rstd_iszero and rstd_mul functions are part of our Rust library, we need to add a new module declaration in our lib.rs file. This step is important because it tells Rust to include the vec module, which contains our functions, in the library compilation.

Here's what we add in lib.rs:

File lib.rs:

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

// This is needed for vec functions to be included in the library!
mod vec;
...

Build and check

Build rustidor library

Now, we must build our lib as a release:

~/$RUSTIDOR_WORKING_DIR/rustidor$ cd rust_port
~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port$ cargo build --release
   Compiling rust_port v0.1.0 (~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port)
    Finished release [optimized] target(s) in 0.32s

Check that the functions are there. You should see a similar output from the nm command:

~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port$ nm target/release/librust_port_lib.a | more

rust_port_lib-bc5126501f3f03b3.rust_port_lib.493c5e149634b1d5-cgu.0.rcgu.o:
0000000000000000 T rstd_getweapon
0000000000000000 T rstd_iszero
0000000000000000 T rstd_mul
                 U __rust_dealloc
                 U strlen
                 U _ZN4core3ffi5c_str4CStr6to_str17h1a09e925bfb46377E
                 U _ZN4core3num62_$LT$impl$u20$core..str..traits..FromStr$u20$for$u20$usize$GT$8from_str17h80670d9e3c2d1c45E

Build Sauerbraten

Now, we've got to tweak the Makefile a bit. This is because the sauer_server program also uses the rstd_iszero function we've been working on. We need to tell the Makefile where to find our Rust library and how to link it.

Around line 147 in the Makefile, you'll want to add the library directory and the instructions for loading the library:

...
endif
SERVER_LIBS+= -L rust_port/target/release -lrust_port_lib
SERVER_OBJS= \
    shared/crypto-standalone.o \
    shared/stream-standalone.o \
...

Then the only remaining steps are to make the game...

~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port$ cd ..
~/$RUSTIDOR_WORKING_DIR/rustidor/src$ make

... and launch it:

~/$RUSTIDOR_WORKING_DIR/rustidor-code/src$ cd ..
~/$RUSTIDOR_WORKING_DIR/rustidor-code$ src/sauer_client

The game itself should look as always.

How to Tell if the Library is Doing Anything?

Want to quickly check if your Rust library is actually kicking in? Here's a simple test. Let's tweak the rstd_mul function so it always multiplies by zero. It's a bit of a hack, but it'll clearly show if the Rust code is being used.

Update rstd_mul in Rust like this:

#[no_mangle]
pub extern "C" fn rstd_mul(vec: *mut c_void, f: c_float) -> *mut c_void {
    let vec = unsafe { &mut *(vec as *mut CVec<c_float>) };
    vec.x *= 0.0; vec.y *= 0.0; vec.z *= 0.0; // This is just for testing!
    vec as *mut CVec<c_float> as *mut c_void
}

Build the library and the game with these changes, then launch it. You should hear the game's music, but the welcome screen will be missing the menu. It's a clear sign that our Rust library is in action.

That's it for now. See you in the next article! Follow me to stay updated with more Rust and C++ adventures.

0
Subscribe to my newsletter

Read articles from jsrmalvarez directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

jsrmalvarez
jsrmalvarez

Engineer, language enthusiast (ES EN FR EO toki pona) C++, Rust, Solidity, web3 MD at https://cherishedlovelocks.io