Rustidor #2: Porting Sauerbraten from C++ to Rust
Luke, you're going to find that many of the truths we cling to depend greatly on our own point of view
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 avoid*
, but in Rust, we map it to acty::c_void
pointer. This means we're going to need thecty
crate for itsc_void
type. - Since our comparison isn't with a float value like
0.0
but with an integer0
, we'll need to treat ourCVec
struct as if it containedc_int
values. So, we'll also bring in thec_int
type from thecty
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.