Rust on iOS with SDL2

A guide to setting up an iOS project using Rust and SDL2

Here's a guide to setting up an iOS project using Rust and SDL2.

EDIT: These instructions tend to rot over time due to changes in xcode and SDL2's project structure.

Initialize a git repo and get SDL2

Run through these commands to init a git repo and grab SDL2.

cd ~/dev/rust
mkdir ios-sdl2
cd ios-sdl2
git init
git submodule add --name SDL2 https://github.com/spurious/SDL-mirror.git SDL2
git commit -m "Initial commit"

Now's a good time to add some things to your .gitignore

/target
Cargo.lock
xcuserdata

Commit the .gitignore

git add .gitignore
git commit -m "Add gitignore file"

Build SDL2

Open SDL2's iOS xcode project

open -a xcode SDL2/Xcode/SDL/SDL.xcodeproj

Set up the build scheme like this and hit play:

EDIT: The screenshot is a bit old, the correct setting is Framework-iOS > Any iOS Device (arm64 armv7)

Once this finishes, you will have a libSDL2.a file... somewhere. (xcode put mine somewhere in ~/Library/Developer/Xcode/DerivedData/HASHED_FOLDER/Build/Products/ - but that's fine. We'll pull it in later.

Include files are in SDL2/include

Setup the xcode project

xcode will be used to deploy your app to the device. There's an example project we can pull from SDL2 to help us get started

cp -r "SDL2/Xcode-iOS/Template/SDL iOS Application" xcode

We need to fix up a few names from the template. There's a placeholder ___PROJECTNAME___ used as both a filename and within some files. (That's PROJECTNAME surrounded by three underscores)

  • Rename ___PROJECTNAME___.xcodeproj
  • Find and replace ___PROJECTNAME___ in ios-sdl2.xcodeproj/project.pbxproj

Here's a script to do that and commit the changes

cd xcode
mv ___PROJECTNAME___.xcodeproj ios-sdl2.xcodeproj
sed -i "" 's/___PROJECTNAME___/ios-sdl2/g' ios-sdl2.xcodeproj/project.pbxproj
cd ..
git add xcode
git commit -m "Add xcode project"

Now launch xcode (the project is in /xcode/ios-sdl2.xcodeproj) and hit the play.

You should get an error like this:

SDL.h file not found

This is because we copy/pasted a template. It has paths that need to be fixed and will not build immediately.

Add SDL2 headers to the include path

  • Open the files "tab" (see image below)
  • Select the root node "ios-sdl2"
  • Select Build Settings at the top
  • Use the search below and to the right and find "User Header Search Paths"
  • Double click and modify the entry to "../SDL2/include"

Fix Broken Reference to SDL2 Library

First, CLOSE ALL INSTANCES OF XCODE. We need to make absolutely sure the SDL2 project is not open. Otherwise the following steps will fail. Now reopen /xcode/ios-sdl2.xcodeproj

We should fix up the SDL.xcodeproj reference that's in the files list. Point it at SDL2/Xcode/SDL/SDL.xcodeproj.

This does a couple things:

  • Makes SDL2 source code available in your project
  • Allows you to easily static link against the output of this library

We need to fix up library reference so that the project builds.

  • Select ios-sdl2 project in the top left corner. This will bring up the project configuration if it isn't already showing
  • Scroll down to the Frameworks, Libraries, and Embedded Content section. You may see libSDL2.a with an empty outline
  • If this is the case, select it and remove it with the "-".
  • Click "+" so we can add it back correctly.
  • Select libSDL2.a from the libSDL-iOS target and click add

Now it should look like this...

Final steps for Running in Simulator

If you try to run it now, you'll likely have a few link errors. Fix this up by going back to the Frameworks, Libraries, and Embedded Content section. Add these frameworks. (Click plus and type in the text box to quickly find it)

  • AVFoundation.framework
  • Security.framework

EDIT: I didn't need to do this step at this point, but I needed to add Security.framework later! (Also, you'll follow this step later to add a rust static library...)

With link errors solved, we get a new error message:

The application's Info.plist does not contain CFBundleShortVersionString.
Ensure your bundle contains a CFBundleShortVersionString.

Open Info.plist and add it.

  • Open the files "tab" if it isn't already open
  • Select Info.plist
  • Right click Information Property List
  • Select "Add Row"
  • Type in CFBundleShortVersionString and hit enter. It will likely change to "Bundle version string (short)" - that's fine
  • Set the value to "0.1.0"

At this point when you hit play, the simulator should come up. SDL2 should load and start drawing!

Deploying to a device

To follow this step, you may need an apple developer account. I'm not going to cover this in detail but I'll just mention briefly a few things I set:

  • In the project settings under General, you can pick a target version, like iOS 12.4
  • In the project settings under Signing & Capabilities, select your team and set a bundle identifier
  • When I switched to iOS 12.4, I needed to add Metal.framework

Finally, write some Rust!

Time to get a rust project up and running! The general approach is that we will create a C library, link it, and call into it. I highly recommend checking out this repo: https://github.com/thombles/dw2019rust. Most of the below steps are covered in detail there.

First thing you'll need to do is grab the rust toolchains for:

  • Physical devices: aarch64-apple-ios
  • Simulators:  x86_64-apple-ios
rustup target add aarch64-apple-ios x86_64-apple-ios

Now create a rust library

cargo new --lib game-ios

Modify the .toml to create a library. I'll also add in sdl2 and a simple logging crate.

[lib]
crate-type = ["lib", "staticlib"]

[dependencies]
sdl2 = "0.34"
env_logger = "0.6"
log="0.4"

And modify lib.rs

#[no_mangle]
pub extern "C" fn run_the_game() {
    env_logger::Builder::from_default_env()
        .filter_level(log::LevelFilter::Info)
        .init();

    log::info!("Started!");
}

Finally, some rust code! Rather than a standard main function, we expose a C-ABI compatible function. The main.c in our xcode project can link against the library created by rust and call into it.

For convenience, create an example. This allows building and running the library outside of xcode. I added examples/run_the_game.rs with:

use game_ios::*;

fn main() {
    run_the_game();
}

Add a workspace file (Cargo.toml) at the root of the project

[workspace]
members = [
    "game-ios",
]

This does two things:

  • Allows you to run cargo build and cargo run --example run_the_game in the base of the project
  • Puts the output folder ("target") in the base of the project

At this point your project structure should look like this:

Linking Rust from xcode

First, let's automate building the rust project from xcode. Here's a script that will work. For information about the "lipo" commands, see here: https://github.com/thombles/dw2019rust/blob/master/modules/02 - Cross-compiling for Xcode.md

set -e
RUST_PROJ="/Users/pmd/dev/rust/ios-sdl2" <-- EDIT TO MATCH YOUR PATH
PATH="$PATH:/Users/pmd/.cargo/bin" <-- EDIT TO MATCH YOUR PATH

cd "$RUST_PROJ"
cargo build --package game-ios --target aarch64-apple-ios --verbose
cargo build --package game-ios --target x86_64-apple-ios --verbose

lipo -create target/aarch64-apple-ios/debug/libgame_ios.a target/x86_64-apple-ios/debug/libgame_ios.a -output target/libgame_ios.a

We can add this to xcode.

  • Open the project settings and under the "Build Phases" section
  • Add a Run Script phase
  • Drag it up to be earlier in the build
  • Add the script. Be sure to update the paths!

More info on setting up build scripts here: https://github.com/thombles/dw2019rust/blob/master/modules/04 - Build automation.md

Errors you might see now:

  • library not found for -ISystem/linking with cc failed: exit code 1: I didn't use to have this problem and hopefully there's a better solution... but running cargo build manually once fixed it for me
  • Missing symbol _run_the_game: follow the steps above "Final steps for Running in Simulator" to add the rust static library to your project. It's in target/libgame_ios.a

This will likely fail with an error message about a missing symbol

Finally, we need to add the rust output directory to the library search path. This is in the same area we updated the include paths for SDL.

Starting the Rust Code

We're close! Update main.c

// Including SDL.h is required to let SDL hook main and set up the app delegate
#include "SDL.h"
#include <stdio.h>

extern void run_the_game();

int main(int argc, char *argv[])
{
    run_the_game();
    printf("run_the_game returned");
    return 0;
}

If you get a linker error "Undefined symbol: _SecRandomCopyBytes", ensure Security.framework is included. (See steps above)

Now you should see in the output:

[2020-07-21T07:08:50Z INFO  game_ios] Started!
run_the_game returned

One More Build Config Setting

Rust does not produce bitcode compatible with xcode LLVM. So to run on a physical device, it must be disabled. It's in the same area as where we modified header/library paths, but make sure to enable showing "All" settings. More info here: https://github.com/thombles/dw2019rust/blob/master/modules/02 - Cross-compiling for Xcode.md#1-use-a-physical-iphone-or-ipad

A More Visual Demo

This code will make the screen change colors and print events to the console


use sdl2::pixels::Color;
use sdl2::event::Event;
use sdl2::keyboard::Keycode;
use std::time::Duration;

#[no_mangle]
pub extern "C" fn run_the_game() {
    env_logger::Builder::from_default_env()
        .filter_level(log::LevelFilter::Info)
        .init();

    log::info!("Started!");

    do_run_the_game().unwrap();
}

pub fn do_run_the_game() -> Result<(), String> {
    let sdl_context = sdl2::init()?;
    let video_subsystem = sdl_context.video()?;
    let window = video_subsystem.window("rust-sdl2 demo", 800, 600)
        .position_centered()
        .opengl()
        .build()
        .map_err(|e| e.to_string())?;
        
    let mut canvas = window.into_canvas().build().map_err(|e| e.to_string())?;

    canvas.set_draw_color(Color::RGB(255, 0, 255));
    canvas.clear();
    canvas.present();
    let mut event_pump = sdl_context.event_pump()?;

    let t0 = std::time::Instant::now();
    
    'running: loop {
        for event in event_pump.poll_iter() {
            println!("{:?}", event);
            match event {
                Event::Quit {..} | Event::KeyDown { keycode: Some(Keycode::Escape), .. } => {
                    break 'running
                },
                _ => {}
            }
        }
    
        let time = (std::time::Instant::now() - t0).as_secs_f32();
        let fraction = (time.sin() + 1.0) / 2.0;

        let color = (fraction * 255.0) as u8;
        canvas.set_draw_color(Color::RGB(0, 0, color)); 
        canvas.clear();
        canvas.present();
        ::std::thread::sleep(Duration::new(0, 1_000_000_000u32 / 30));
    }

    Ok(())
}

Commit all the changes

git add .
git commit -m "Rust app works"

And the output is here: