Demilade Sonuga's blog

All posts

Automating The Build Process

2022-12-24

I'm sure that by now, you would have gotten tired of typing in these commands to run the game:

cargo build

sudo qemu-system-x86_64 \
    -drive if=pflash,format=raw,unit=0,file=/path/to/OVMF_CODE.fd,readonly=on \
    -drive if=pflash,unit=1,format=raw,file=/path/to/OVMF_VARS.fd \
    -drive format=raw,file=fat:rw:target/x86_64-unknown-uefi/debug/

You might already have some way of avoiding the need to type this out every time, maybe a shell script or a simple copy-and-paste scheme.

What we're going to do now is to make this process easier for ourselves. We want running this project to be as easy as just typing in cargo run and hitting enter. Because of the no_std nature of this project, we won't be able to use cargo run (as is), since cargo run attempts to run the project with the OS you're developing with.

Rather than trying to hack our way with cargo run, we'll create a utility crate that will do the heavy lifting for us.

This utility is going to be called runner and our aim is that cargo runner should build and run the Blasterball project. Before we get on to that, we're first going to need to do some directory restructuring.

At this point, the directory looks like this:

blasterball/
| .git/   // Hidden, if you're using git
| .cargo/
| | config.toml
| src/
| | bitmap.rs
| | block.bmp
| | font.rs
| | main.rs
| | uefi.rs
| .gitignore
| Cargo.lock
| Cargo.toml

We're going to change it to this:

blasterball-project/
|  .git/ // Hidden, if you're using git
|  .cargo/
|  |  config.toml
|  blasterball/
|  | src/
|  | | bitmap.rs
|  | | block.bmp
|  | | font.rs
|  | | main.rs
|  | | uefi.rs
|  | Cargo.lock
|  | Cargo.toml
|  runner/
|  |  src/
|  |  |  main.rs
|  |  Cargo.toml
|  .gitignore
|  Cargo.lock
|  Cargo.toml

The first thing you need to do is create a new directory blasterball-project outside your blasterball directory.

mkdir blasterball-project

Now move over your blasterball directory into blasterball-project.

mv blasterball blasterball-project

If you're using git, you have to move .git/ out of blasterball/ into blasterball-project/.

Assuming your current directory is the blasterball-project directory:

mv blasterball/.git .

Now, you have to let Git know of your changes (Again, only if you're using git). Assuming your current directory is the blasterball-project directory:

git add .
git commit -m "Moved the blasterball crate into the blasterball-project workspace"

In your blasterball-project directory, run

cargo new --bin runner

Now you have two binaries in blasterball-project, the runner which we'll use for running the game and the game itself, blasterball.

In the same blasterball-project directory, create a new Cargo.toml file and throw in the following:

[workspace]
members = [
    "blasterball",
    "runner"
]

This is just to indicate to cargo that the two binaries belong to the same workspace. A workspace in Rust is just a way of organizing code such that multiple crates are logically grouped together.

In the blasterball-project directory, create a new .cargo/config.toml and add:

[alias]
runner = "run --package runner"

Now, whenever cargo runner is run in the blasterball-project/ directory, the runner crate will be built and executed.

Try running cargo runner now and you should have "Hello, world!" printed out.

Open your runner's main.rs file and you'll see:

fn main() {
    println!("Hello, world!");
}

which is the default code in a cargo-initialized project.

When this runner is run, it ought to do the following:

  1. Build blasterball
  2. Run blasterball

We start from step 1. To build blasterball, we first need a way of invoking cargo build on it from the code. The standard library offers utilities we can use for that in std::process.

Before we continue, at the beginning of this blog series, it was said that the only crates we'll be using to build the game are core and compiler_builtins. This runner is not the game, but rather a utility that we're just going to use to run the game in an emulator, so using the standard library here is not a problem.

The std::process::Command struct provides us with the basic stuff we need to invoke other programs, like cargo. To check it out, throw this into runner's main.rs:

use std::process::Command;

fn main() {
    let output = Command::new("ls")
        .arg("-l")
        .output()
        .unwrap();
    println!("{}", std::str::from_utf8(&output.stdout).unwrap());
}

This imports the standard library's Command struct and uses it to invoke the ls command which lists the items in the current directory. Running it should give you something like this:

total 8
drwxr-xr-x. 1 demilade demilade  70 Dec 22 14:47 blasterball
-rw-r--r--. 1 demilade demilade 202 Dec 23 13:45 Cargo.lock
-rw-r--r--. 1 demilade demilade  57 Dec 23 13:45 Cargo.toml
drwxr-xr-x. 1 demilade demilade  26 Dec 22 14:47 runner
drwxr-xr-x. 1 demilade demilade 118 Dec 22 14:31 target

Okay, now that we know how to start another process from our code, we just run cargo build --package blasterball with the Command struct's mechanism to build the game.

One more thing we need to note is this:

[build]
target = "x86_64-unknown-uefi"

[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]

These are the contents of blasterball's .cargo/config.toml file. The configuration in this file will no longer be taking effect. You can delete the file now. Instead of configuring blasterball's build with the config file, we'll be passing it directly to the cargo build invocation with the --target x86_64-unknown-uefi and -Zbuild-std=core options.

In runner's main.rs:

/* DELETED:
fn main() {
    let output = Command::new("ls")
        .arg("-l")
        .output()
        .unwrap();
    println!("{}", std::str::from_utf8(&output.stdout).unwrap());
}
*/

fn main() {
    let status = Command::new("cargo")
        .arg("build")
        .arg("--package")
        .arg("blasterball")
        .arg("-Z")
        .arg("build-std=core")
        .arg("--target")
        .arg("x86_64-unknown-uefi")
        .status()
        .unwrap();
    if !status.success() {
        eprintln!("Failed to build the game");
        std::process::exit(1);
    }
}

The above is equivalent to cargo build --package blasterball -Zbuild-std=core --target x86_64-unknown-uefi. The status which is invoked after the arg declarations executes the command and returns the exit status. If the status says it failed, an error message is printed to standard error and execution stops here.

We're done with step 1. Our game runner now builds the game. All that's left is to run it. Running it by hand, the command looks like this:

sudo qemu-system-x86_64 \
    -drive if=pflash,format=raw,unit=0,file=/path/to/OVMF_CODE.fd,readonly=on \
    -drive if=pflash,unit=1,format=raw,file=/path/to/OVMF_VARS.fd \
    -drive format=raw,file=fat:rw:target/x86_64-unknown-uefi/debug/

In our runner's main function:

// NEW:
const OVMF_PATH: &'static str = "/path/to/ovmf/";

fn main() {
    let status = Command::new("cargo")
        .arg("build")
        .arg("--package")
        .arg("blasterball")
        .arg("-Z")
        .arg("build-std=core")
        .arg("--target")
        .arg("x86_64-unknown-uefi")
        .status()
        .unwrap();
    if !status.success() {
        eprintln!("Failed to build the game");
        std::process::exit(1);
    }

    // NEW:
    let status = Command::new("sudo")
        .arg("qemu-system-x86_64")
        .arg("-drive")
        .arg(&format!("if=pflash,format=raw,unit=0,file={}/OVMF_CODE.fd,readonly=on", OVMF_PATH))
        .arg("-drive")
        .arg(&format!("if=pflash,unit=1,format=raw,file={}/OVMF_VARS.fd", OVMF_PATH))
        .arg("-drive")
        .arg("format=raw,file=fat:rw:target/x86_64-unknown-uefi/debug/")
        .status()
        .unwrap();
    if !status.success() {
        eprintln!("Failed to start emulator");
        std::process::exit(1);
    }
}

The translation to Rust is very straightforward due to Command's well-designed API. The OVMF_PATH at the top there should be replaced with whatever it is on your own computer. On mine, it's /usr/share/edk2/ovmf/.

If you're not already familiar with it, the format! macro takes arguments like println!'s but returns a string instead of printing to the terminal.

Running cargo runner in the top-level directory now will go through the process of building the game and invoking the emulator.

Take Away

  • The Command struct in the standard library is used for creating new processes.

Your directory should be looking like this now:

blasterball-project/
|  .git/ // Hidden, if you're using git
|  .cargo/
|  |  config.toml
|  blasterball/
|  | src/
|  | | bitmap.rs
|  | | block.bmp
|  | | font.rs
|  | | main.rs
|  | | uefi.rs
|  | Cargo.lock
|  | Cargo.toml
|  runner/
|  |  src/
|  |  |  main.rs
|  |  Cargo.toml
|  .gitignore
|  Cargo.lock
|  Cargo.toml

For the full code, go to the repo

In The Next Post

We'll get on to animating bitmaps

References

  • https://doc.rust-lang.org/std/process/struct.Command.html