Rustidor #1: Porting Sauerbraten from C++ to Rust
The right way to eat is the same as the right way to live: be “just, cheerful, equable, temperate, and orderly.”
Table of contents
This is the first post in a series dedicated to porting the epic FPS game Sauerbraten from C++ to Rust. In this post, we focus more on setting up the project rather than delving into how to implement C++ functions in Rust.
Installation
Development Packages
To get started, open a terminal and install the following packages. I assume you're using an apt-based Linux distro. Users of other package managers should use analogous syntax:
~$ sudo apt install build-essential git subversion
~$ sudo apt install libsdl2-dev libsdl2-mixer-dev libsdl2-image-dev
SDL Libraries
These libraries are necessary for the game to run. Install them using the following command:
~$ sudo apt install libsdl2-2.0-0 libsdl2-mixer-2.0-0 libsdl2-image-2.0-0
Rust installation
- Install Rust using rustup by following the instructions at https://rustup.rs/
- Install Clippy to receive advice on your coding style:
~$ rustup component add clippy
Codebase
Navigate to the directory where you want to set up this project. For example, let's call it $RUSTIDOR_WORKING_DIR
, but you can replace it with your preferred name:
~$ mkdir $RUSTIDOR_WORKING_DIR
~$ cd $RUSTIDOR_WORKING_DIR
~/$RUSTIDOR_WORKING_DIR$ mkdir sauerbraten_port && cd sauerbraten_port
Download the original Sauerbraten code from the official SVN repository. This will provide the images, maps, sounds, etc., which are licensed under a different license than the code:
~/$RUSTIDOR_WORKING_DIR$ svn checkout svn://svn.code.sf.net/p/sauerbraten/code/@r6852 sauerbraten-code
Next, download the rustidor
code:
~/$RUSTIDOR_WORKING_DIR$ git clone https://github.com/jsrmalvarez/rustidor.git
Test Sauerbraten
Now, it's time to build and run the game to ensure that we have a solid starting point.
To build the game, execute the following commands:
~/$RUSTIDOR_WORKING_DIR$ cd sauerbraten-code/src
~/$RUSTIDOR_WORKING_DIR/sauerbraten-code/src$ make
Wait for the executable to compile. It should complete without any errors. After that, run the game with:
~/$RUSTIDOR_WORKING_DIR/sauerbraten-code/src$ cd ..
~/$RUSTIDOR_WORKING_DIR/sauerbraten-code$ src/sauer_client
You should see the welcome screen, hear the heavy metal music, and be able to start a bot match, for example. When you're done defeating bots, return to the working directory:
~/$RUSTIDOR_WORKING_DIR/sauerbraten-code$ cd ..
~/$RUSTIDOR_WORKING_DIR$
Linking to non-redistributable assets
Sauerbraten includes images, maps, sounds, etc., which are licensed separately from the code. The game needs these assets to run, which is why we downloaded them from the official repository.
Change to the rustidor
directory (where we previously cloned the rustidor
Git repository):
~/$RUSTIDOR_WORKING_DIR$ cd rustidor
Let's checkout the branch for this post
~/$RUSTIDOR_WORKING_DIR/rustidor$ git checkout post#1
Build symbolic links to data
and packages
directories:
~/$RUSTIDOR_WORKING_DIR/rustidor$ ln -s ../sauerbraten-code/data data
~/$RUSTIDOR_WORKING_DIR/rustidor$ ln -s ../sauerbraten-code/packages packages
Now, we are all set to start coding!
Port the first function
We are going to write a Rust static library with the implementation of some functions and some wrappers to exchange data between C/C++ and Rust. This is needed because the binary data format is not the same.
Rust projet
Lets create a new rust library project under src
directory
~/$RUSTIDOR_WORKING_DIR/rustidor$ cd src
~/$RUSTIDOR_WORKING_DIR/rustidor/src$ cargo new --lib rust_port
Created library `rust_port` package
~/$RUSTIDOR_WORKING_DIR/rustidor/src$
Then, edit file ~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port/Cargo.toml
to configure the project as a static library adding the lib section:
[package]
...
[lib]
name = "rust_port_lib"
crate-type = ["staticlib"]
...
Change to rust_port folder if you haven't already and execute cargo build. No errors should appear.
~/$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
Lets code!
Port the implementation
The first function we are going to port is game::getweapon
from file $RUSTIDOR_WORKING_DIR/rustidor/src/fpsgame/weapon.cpp
int getweapon(const char *name)
{
const char *abbrevs[] = { "FI", "SG", "CG", "RL", "RI", "GL", "PI" };
if(isdigit(name[0])) return parseint(name);
else loopi(sizeof(abbrevs)/sizeof(abbrevs[0])) if(!strcasecmp(abbrevs[i], name)) return i;
return -1;
}
This function takes a null-terminated C string as a parameter. Initially, it checks if the string can be converted into a numerical value and returns that number if the conversion is successful. If the string cannot be converted to a number, the function proceeds to perform a case-insensitive search for the name within the abbrevs
array. It returns the index of the matching name or -1 if no match is found
Let's implement that functionality à la Rust.
Open $RUSTIDOR_WORKING_DIR/sauerbraten-code/rustidor/src/rust_port/src/lib.rs
Remove add
function and put this one instead:
fn get_weapon(name: &str) -> Option<usize>{
if let Ok(res) = name.parse() {
Some(res)
}
else {
const ABBREVS:[&str;7] = ["FI", "SG", "CG", "RL", "RI", "GL", "PI"];
ABBREVS.iter().position(|&x| { x == name.to_uppercase() })
}
}
This get_weapon
rust function takes a string slice with the name of a weapon or a number. It returns an Option<usize>
. If name can be parsed to an usize
value it will return that value. If it matches ()case insensitive) with one of the &str
values in ABBREVS array, it will return the matching index. It will return None otherwhise.
The get_weapon
Rust function accepts a string slice containing the name of a weapon or a number and returns an Option<usize>
. If the provided name can be successfully parsed into a usize
value, it will return that value. However, if the name matches (in a case-insensitive manner) with any of the &str
values within the ABBREVS
array, the function will return the corresponding index. In all other cases, it will return None
.
C wrapper
C will not use &str
to call get_weapon
and it won't accept an Option<usize>
as return value. We need a wrapper function around get_weapon
that provides input and output types compatibility.
Add these lines to the beginning of lib.rs
file:
use std::os::raw::c_char;
use std::ffi::CStr;
/// # Safety
#[no_mangle]
pub unsafe extern "C" fn rstd_getweapon(ptr: *const c_char) -> i32 {
let c_str = CStr::from_ptr(ptr);
let rust_str = c_str.to_str().expect("Bad encoding");
match get_weapon(rust_str) {
Some(weapon_index) => i32::try_from(weapon_index).unwrap_or(-1),
_ => -1
}
}
This rstd_getweapon
wrapper function receives a type compatible with C const char*
that can be converted to &str
to be passed to get_weapon
. Then, the value returned from get_weapon
is converted to i32
, which is (for almost any non-esoteric system) compatible with C's int
type.
The # Safety
doc comment is suggested by Clippy, as the function is declared to be unsafe (due to a pointer dereference when calling CStr::from_ptr
). The #[no_mangle]
attribute tells the linker to use the function name as is, and the extern "C"
part makes this function adhere to the C calling convention for our system.
Test it
Let's add some tests to check that get_weapon
implementation and to be sure that rstd_getweapon
behaviour matches the one of the C. At the end of lib.rs
file, replace the default tests generated by cargo
with these:
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn numeric_input() {
assert_eq!(get_weapon("2"), Some(2));
assert_eq!(get_weapon("42"), Some(42));
}
#[test]
fn name_input() {
assert_eq!(get_weapon("FI"), Some(0));
assert_eq!(get_weapon("sG"), Some(1));
assert_eq!(get_weapon("cg"), Some(2));
assert_eq!(get_weapon("RL"), Some(3));
assert_eq!(get_weapon("RI"), Some(4));
assert_eq!(get_weapon("Gl"), Some(5));
assert_eq!(get_weapon("pi"), Some(6));
}
#[test]
fn erroneous_input() {
assert_eq!(get_weapon(""), None);
assert_eq!(get_weapon("a"), None);
assert_eq!(get_weapon("-8"), None);
assert_eq!(get_weapon("KK"), None);
}
}
#[cfg(test)]
mod c_integration_tests {
use super::*;
use std::ffi::CString;
#[test]
fn number_input(){
let mut c_string:CString;
c_string = CString::new("0").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 0);
c_string = CString::new("42").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 42);
}
#[test]
fn name_input(){
let mut c_string:CString;
c_string = CString::new("FI").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 0);
c_string = CString::new("sG").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 1);
c_string = CString::new("cg").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 2);
c_string = CString::new("RL").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 3);
c_string = CString::new("RI").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 4);
c_string = CString::new("Gl").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 5);
c_string = CString::new("pi").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, 6);
}
#[test]
fn erroneous_input() {
let mut c_string:CString;
c_string = CString::new("").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, -1);
c_string = CString::new("a").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, -1);
c_string = CString::new("-8").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, -1);
c_string = CString::new("KK").unwrap();
assert_eq!(unsafe{ rstd_getweapon(c_string.as_ptr()) }, -1);
}
}
As you can see, these comprehensive tests verify that the function correctly handles different scenarios: it checks that a numeric input is successfully converted to a number, that an input matching one of the strings assigned to a weapon returns the corresponding index, and that other strings result in the function returning -1.
Let's run the tests. You should see an output similar to this, with all the test passed:
~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port$ cargo test
Compiling rust_port v0.1.0 (~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port)
Finished test [unoptimized + debuginfo] target(s) in 0.22s
Running unittests src/lib.rs (target/debug/deps/rust_port_lib-223b9f9730e652cb)
running 6 tests
test c_integration_tests::erroneous_input ... ok
test c_integration_tests::number_input ... ok
test unit_tests::name_input ... ok
test unit_tests::erroneous_input ... ok
test unit_tests::numeric_input ... ok
test c_integration_tests::name_input ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Build the library
Let's build the library. Call cargo build
and check that librust_port_lib.a
file is under
target/release
directory
~/$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
~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port$ ls target/release
build deps examples incremental librust_port_lib.a librust_port_lib.d
Replace C++ code
Now it's time to replace the implementation in C++ code for a call to our function.
Go back to C++ function game::getweapon
in file $RUSTIDOR_WORKING_DIR/rustidor/src/fpsgame/weapon.cpp
and replace the code inside with the call to our function.
int getweapon(const char *name)
{
return rstd_getweapon(name);
}
Create and include new header file
This function is undeclared. We need to declare it in a header. Let's create a include directory:
~/$RUSTIDOR_WORKING_DIR/rustidor/src/rust_port$ mkdir include
Put this code in a new file under the include
directory and save it as rust_port.h
#ifndef RUSTIDOR
#define RUSTIDOR
extern "C" {
int rstd_getweapon(const char* c_char);
}
#endif//RUSTIDOR
Now add #include "rust_port.h"
at the top of $RUSTIDOR_WORKING_DIR/rustidor/src/fpsgame/weapon.cpp
file.
Update makefile
Let's edit the Makefile to tell gcc where the new header is and where the new library is.
Edit $RUSTIDOR_WORKING_DIR/rustidor/src/Makefile
file
The INCLUDES
line should be updated like this, to add the folder of the header
...
INCLUDES= -Ishared -Iengine -Ifpsgame -Ienet/include -Irust_port/include
...
Around line 79, the library directory and the instruction to load the library should be added:
...
endif
endif
CLIENT_LIBS+= -L rust_port/target/release -lrust_port_lib
CLIENT_OBJS= \
shared/crypto.o \
shared/geom.o \
...
Test Sauerbraten again
Let's build the game and test it.
~/$RUSTIDOR_WORKING_DIR$ cd rustidor/src
~/$RUSTIDOR_WORKING_DIR/rustidor/src$ make
After the build, launch the game:
~/$RUSTIDOR_WORKING_DIR/rustidor-code/src$ cd ..
~/$RUSTIDOR_WORKING_DIR/rustidor-code$ src/sauer_client
Everything should be as usual. Specially check that you can start a bot match and change weapons (mouse wheel or number keys)
All the code
Remember that all the code can be found at rustidor github repository