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

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.”

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 addfunction 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