Learning motivation: discover Rust with Zork

24 minute(s) read

To keep on renewing my computer skills, I try to get in touch with new frameworks, tools or languages regularly. But it can be hard to motivate yourself into learning something without a goal.

A concrete way to get enough interest in learning is to choose a side project based on this technology.

Note: This article does not focus on an academic learning, it’s just a quick way to get in touch with a language.

What technology to learn?

This part is quite simple: maybe you need to learn something for your work or you’re interested in a specific technology.

Rust Logo In my case:

  • I like to work on Raspberry Pis and Linux.
  • I would like to use VIM as an IDE (works easily through SSH on Raspberries :) ).
  • Moreover, I would like to get in touch with the Rust programming language.

(I already tried Go and would like to experiment the possibilities of the two languages)

What to pick as a side project?

There are many ways to discover a computer programming project, such as contributing to an open-source project or trying to solve mathematical problems (You could find many examples on Project Euler web site).

In order to stay motivated in your learning journey, you should choose a project linked to one of the following:

  • One of your hobbies
  • Your day-to-day job
  • Your day-to-day life

You could for instance develop a tool to assist you in your work or your family life. (caring for children or dealing with errands can be valid side project sources)

In this exemple, I’ll focus on my age and nostalgia :) Well, I’m old enough to remember interactive fictions.

Interactive fictions

If you’re not aware of those games, here is a quick (and famous) sample:

Zork Welcome Screen

This screenshot is the opening screen of the game “Zork” by Infocom (and if you’re brave enough, you can play it online here: Zork on TextAdventures.co.uk).

No graphics, no sound, only text and imagination: interactive fictions were born in the ’70s and popularized with games like Adventure or Zork (the very first version of Zork was released in 1977 on PDP-1).

Players use text commands to control the characters and interact with the story. It generally involves lots of notes and map drawing.

Game engine

Infocom Logo Publishers of Interactive Fiction, especially Infocom, relied on a common binary format for their games. For Infocom, the corresponding data files where interpreted by a virtual machine named: Z-machine.

From Wikipedia:

The Z-machine is a virtual machine that was developed by Joel Berez and Marc Blank in 1979 and used by Infocom for its text adventure games. Infocom compiled game code to files containing Z-machine instructions (called story files, or Z-code files), and could therefore port all its text adventures to a new platform simply by writing a Z-machine implementation for that platform.

Resources are available online describing the Z-machine and how to emulate it. Moreover, it’s easy to find legal game files to play with the machine.

Starting the project

So, let’s mix things together:

  • I would like to try the Rust programming language
  • I like interactive fictions

Why not build a tool to analyze the content of a Z-machine file? (maybe we’ll try to produce a small interpreter, but there’s already a lot of them, so one thing at a time :) )

Moreover, there’s no need for graphics for this project, a good point: everything can run inside a SSH console.

Ok, let’s get started!

Getting started with Rust

Online resources are available to get started (https://www.rust-lang.org/learn/get-started and https://www.rust-lang.org/learn. I’ll just list the install commands hereafter.

Install On Linux

pi@darkstar:~/src $ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Install On macOS

$ brew install rustup
$ rustup-init

Creating the project

Cargo Logo The Rust toolchain provides a dedicated package manager named “Cargo” to create your project, manage its lifecycle and fetch its dependencies (See The Cargo Book online for more details on the tooling).

Creating a new project with cargo is straightforward:

pi@darkstar:~/src $ cargo new z-rust
     Created binary (application) `z-rust` package

If you check the directory content, you will find:

pi@darkstar:~/src/z-rust $ tree .
.
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

Where:

  • Cargo.toml is the manifest, giving more details on the project, its author and the dependencies required for the build
  • main.rs is the main Rust program of your project

Step 1: Access a Z-machine file and find its size

We could start with a simple “Hello World” (have you tried the command cargo run? ;)), but I prefer starting with something more “spicy”: Open a file and get its size.

We need a game file. We can easily download the minizork demo that will be perfect for our task:

curl -XGET http://www.ifarchive.org/if-archive/infocom/demos/minizork.z3 -O

Now, let’s modify our code to access the file and display its infos.

With a quick search accross the documentation we find that the following parts of Rust will be used:

The prinln! macro is straightforward: it writes its arguments to the standard output followed by a new line.

The fs::metadata function is more complicated: it accepts a filename parameter and returns a io::Result enum encapsulating a Metadata structure .

The io::Result structure allows error handling whereas the Metadata structure contains the actual result. For our present task, this structure provides a len() method giving us the file size.

And as metadata is part of the std::fs module, we need to import it in our program with the use keyword.

Result and error handling

The io::Result enum is used for returning results in functions that may produce an error.

Dealing with errors is described in the documentation. Here we learn that pattern matching can be applied via the match keyword to process the returned values. Result contains 2 possible values:

  • Ok for success
  • Err for failure

Easy? Well, let’s try it!

Edit the src/main.rs file with the following content:

use std::fs;

fn main() {
    println!("Z-machine file infos");
    let meta = fs::metadata("minizork.z3");
    match meta {
        Ok(m) => println!(" - File size: {} bytes", m.len()),
        Err(e) => println!("Error parsing file: {:?}",e),
    }
}

Build & run it with cargo run (with the minizork.z3 file in the current folder).

pi@darkstar:~/src/z-rust $ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/z-rust`
Z-machine file infos
 - File size: 52216 bytes

It’s a win!!

What have we learnt from this step?

Let’s have a look on the main concepts seen in this first step.

Rust conceptDescription
entrypointThe entrypoint of a rust program is the main() function found in the src/main.rs file.
modulesTo use a module, we must declare it with the use keyword in the file header.
println! and substitutionRust provides macros like println! and string substitution is available to display variable content. ({} is used for simple display value substitution, whereas {:?} provides debug mode).
variablesYou can declare a variable via the let keyword, the variable type is automatically inferred.
get file info with fs:metadata()The complete API documentation is available online with code samples.
result and error processingReturn from functions producing errors can be handled with pattern matching via the match keyword.
cargoRust build and package management is delegated to the cargo tool.

Step 2: Accept the filename as parameter

Now that we have access to the filesystem, let’s try to pass the file name as parameter.

We will use the env::args() function which returns an iterator on the arguments of the program. We only need the first parameter of our program, so we will try to access this one via the nth() method of the iterator.

This method returns an Option enum, meaning that it might not return any value:

  • If a parameter is provided, it will be available via the Someelement of the Option enum.
  • If no parameter is available, then the None element is used.

As seen on the first step, processing enum return values will be done with the match keyword.

use std::env;
use std::fs;

fn main() {
    println!("Z-machine file infos");
    let firstArg = env::args().nth(1);

    match firstArg {
        Some(fileName) =>  {
            let meta = fs::metadata(fileName);
            match meta {
                Ok(m) => println!(" - File size: {} bytes", m.len()),
                Err(e) => println!("Error parsing file: {:?}",e),
            }
        }
        None => println!("No file provided"),
    }

Build & run it with cargo run minizork.z3.

pi@darkstar:~/usr/src/z-rust $ cargo run minizork.z3
   Compiling z-rust v0.1.0 (/home/pi/usr/src/z-rust)
warning: variable `firstArg` should have a snake case name
 --> src/main.rs:6:9
  |
6 |     let firstArg = env::args().nth(1);
  |         ^^^^^^^^ help: convert the identifier to snake case: `first_arg`
  |
  = note: `#[warn(non_snake_case)]` on by default

warning: variable `fileName` should have a snake case name
 --> src/main.rs:9:14
  |
9 |         Some(fileName) =>  {
  |              ^^^^^^^^ help: convert the identifier to snake case: `file_name`

warning: 2 warnings emitted

    Finished dev [unoptimized + debuginfo] target(s) in 0.99s
     Running `target/debug/z-rust minizork.z3`
Z-machine file infos
 - File size: 52216 bytes

Our program works but we get 2 warnings providing an interesting information:

In Rust, variables should have a snake case name.

As a Java developer, I used camel case to name my variables. By renaming our variables to snake case, we get no more warning:

pi@darkstar:~/usr/src/z-rust $ cargo run minizork.z3
    Finished dev [unoptimized + debuginfo] target(s) in 0.99s
     Running `target/debug/z-rust minizork.z3`
Z-machine file infos
 - File size: 52216 bytes

What have we learnt from this step?

Rust conceptDescription
program argumentsAn iterator on program arguments is available through the args() function of the std::env module.
iteratorsIn Rust, iterators provide a nth() function for accessing elements by position, returning an enum with Some/None values
namingRust identifiers should follow a snake case naming. Rust provides checking of the naming conventions in its tooling.

Step 3: Refactoring our program with functions

Before trying to explore the Zork file, we will try to improve the code with functions.

A function that takes a String parameter

This refactoring is pretty straightforward:

  • the main() function is responsible for parsing the parameters
  • the new print_file_info function contains our ‘file reading’ code

The syntax is self explanatory:

use std::env;
use std::fs;

fn main() {
    println!("Z-machine file infos");
    let first_arg = env::args().nth(1);

    match first_arg {
        Some(file_name) => print_file_info(file_name),
        None => println!("No file provided"),
    }
}

// Our new function taking the file name as parameter
fn print_file_info(file_name: String) {
    let meta = fs::metadata(file_name);
    match meta {
       Ok(m) => println!(" - File size: {} bytes", m.len()),
       Err(e) => println!("Error reading file: {:?}", e),
    }
}

Dealing with return values and the ? operator

Now we will introduce a return type by delegating error processing to the main function. Function errors return is done by encapsulating return values and errors in a Result enum.

This enum can contain 2 values:

  • Ok, which contains the success value
  • Err, which contains the error value

To ease error handling of functions, Rust introduces the ? operator. This operator allows a more concise process of Result enums. When a function returns a Result enum, the ? operator:

  • unwraps the success value if OK and returns the value
  • returns from the calling function with a Result reencapsulating the error in case of failure.

Here, we will replace the following code:

let meta = fs::metadata(file_name);
match meta {
  Ok(m) => //...,
  Err(e) =>  ///...,
}

with:

let size = fs::metadata(file_name)?.len();

meaning that the size variable will contain the file length if OK, but that the encapsulating function will return an Err Result in case of failure.

The success value type is defined in the <> folowing the Result declaration in our function signature. As our function doesn’t return any success value, the return type declared in our function signature will be: std::io::Result<()>

Which gives us the following code:

use std::env;
use std::fs;

fn main() {
    println!("Z-machine file infos");
    let first_arg = env::args().nth(1);

    match first_arg {
        Some(file_name) => {
            let res = print_file_info(file_name);
            if res.is_err() {
                println!("Error reading file: {:?}", res.err());
            }
        },
        None => println!("No file provided"),
    }
}

// Our new function taking the file name as parameter and returning errors
fn print_file_info(file_name: String) -> std::io::Result<()> {
    // Read file metadata
    let size = fs::metadata(file_name)?.len();
    println!(" - File size: {} bytes", size);

    Ok(())
}

One last note: the Ok(()) at the end of our function returns a success Result containing an empty () value. Note that there’s no trailing ;, meaning the value is returned.

What have we learnt from this step?

Rust conceptDescription
functionsFunctions in Rust are declared with the fn keyword
return typeThe return type of a function is defined by providing a -> in the function signature
return valueThe return value of a function is provided by the final expression of the function, the return keyword is not necessary
? operatorThe ? allows easier error handling by propagating the error to the calling function
is_err() functionOn a Rust Result, the is_err() function returns true if the result is Err

Step 4: Get the same infos as the Unix “file” command

As the next goal, we’ll try to get a result similar to the Unix file command when used on a Z-machine file:

pi@darkstar:~/usr/src/z-rust $ file minizork.z3
minizork.z3: Infocom (Z-machine 3, Release 34, Serial 871124)

Reading the file header

The next elements we want to obtain from the file are stored in the header of the file. Based on the Z-machine Spec, the target offsets are:

InformationOffset (hex)
Z-code version#00
Release number#02-03
Serial (ASCII)#12-17

Reading the file content

We will try to read those 3 values from the file by loading 24 bytes (18 in hexadecimal) into a buffer.

use std::env;
use std::fs;
use std::fs::File;
use std::io::Read;

fn main() {
    let first_arg = env::args().nth(1);

    match first_arg {
        Some(file_name) => {
            let res = print_file_info(file_name);
            if res.is_err() {
                println!("Error reading file: {:?}", res.err());
            }
        }
        None => println!("No file provided"),
    }
}

fn print_file_info(file_name: String) -> std::io::Result<()> {
    // Read file metadata
    let size = fs::metadata(file_name)?.len();

    if size > 24 {
        // Read 24 bytes of the file to get header infos
        let mut f = File::open(file_name)?;
        let mut buffer = [0 as u8; 24];
        f.read(&mut buffer)?;

        let machine_version = buffer[0];
        let release = 0;
        let serial = 0;

        // Display the infos
        println!(
            "{}: Infocom (Z-machine {}, Release {}, Serial {})",
            file_name, machine_version, release, serial
        );
    }
    Ok(())
}

Things to notice in the code:

  • we used the ? operator to propagate errors on reading the file
  • we used the mut keyword to define mutable variables
  • the &mut passed to the f.read() function allows pass-by-reference mutation
  • u8 is the primitive type for 8 bit unsigned integers, the as keyword in the following line casts the 0 value to u8. This syntax declares an array of 24 bytes initialized with zeroes:
    let mut buffer = [0 as u8; 24]; 
    

But, when you try to run this program you get the following errors:

error[E0382]: use of moved value: `file_name`
  --> src/main.rs:26:32
   |
20 | fn print_file_info(file_name: String) -> std::io::Result<()> {
   |                    --------- move occurs because `file_name` has type `std::string::String`, which does not implement the `Copy` trait                                                  
21 |     // Read file metadata
22 |     let size = fs::metadata(file_name)?.len();
   |                             --------- value moved here
...
26 |         let mut f = File::open(file_name)?;
   |                                ^^^^^^^^^ value used here after move

What happens here?

Ownership & moving / borrowing variables

To make memory safety guarantees without relying on a dynamic garbage collector, Rust introduces the concept of Ownership.

Let’s take a look at the ownership rules as listed in the Rust manual:

  • Each value in Rust has a variable that’s called its owner.
  • There can only be one owner at a time.
  • When the owner goes out of scope, the value will be dropped.

What happens here is that the file_name variable’s ownership is given to the metadata function (as it might modify the content of the string) and as such, no use of the variable is authorized after the call to fs::metadata().

A quick solution to this problem is to use the clone() function that creates a deep copy of the variable given as parameter.

The following code will now work properly:

use std::env;
use std::fs;
use std::fs::File;
use std::io::Read;

fn main() {
    let first_arg = env::args().nth(1);

    match first_arg {
        Some(file_name) => {
            let res = print_file_info(file_name);
            if res.is_err() {
                println!("Error reading file: {:?}", res.err());
            }
        }
        None => println!("No file provided"),
    }
}

fn print_file_info(file_name: String) -> std::io::Result<()> {
    // Read file metadata
    let size = fs::metadata(file_name.clone())?.len();

    if size > 24 {
    // Read the file to get header infos
        let mut f = File::open(file_name.clone())?;
        let mut buffer = [0 as u8; 24];
        f.read(&mut buffer)?;

        let machine_version = buffer[0];
        let release = 0;
        let serial = 0;

        // Display the infos
        println!(
            "{}: Infocom (Z-machine {}, Release {}, Serial {})",
            file_name, machine_version, release, serial
        );
    }
    Ok(())
}

Filling the Release and Serial numbers

Now that we have the Z-machine code version, we can process the Release and Serial numbers (read from the file header).

use std::env;
use std::fs;
use std::fs::File;
use std::io::Read;
use std::convert::TryInto;

fn main() {
    let first_arg = env::args().nth(1);

    match first_arg {
        Some(file_name) => {
            let res = print_file_info(file_name);
            if res.is_err() {
                println!("Error reading file: {:?}", res.err());
            }
        }
        None => println!("No file provided"),
    }
}

fn print_file_info(file_name: String) -> std::io::Result<()> {
    // Read file metadata
    let size = fs::metadata(file_name.clone())?.len();

    if size > 24 {
    // Read the file to get header infos
        let mut f = File::open(file_name.clone())?;
        let mut buffer = [0 as u8; 24];
        f.read(&mut buffer)?;

        let machine_version = buffer[0];
        let release = u16::from_be_bytes(buffer[2..4].try_into().unwrap());
        let serial = buffer_to_string(&buffer[18..24]);

        // Display the infos
        println!(
            "{}: Infocom (Z-machine {}, Release {}, Serial {})",
            file_name, machine_version, release, serial
        );
    }
    Ok(())
}

fn buffer_to_string(b: &[u8]) -> String {
  std::str::from_utf8(&b).unwrap().to_string()
}

To note:

  • the release number is taken from the buffer and converted to a u16 word (u16::from_be_bytes() function)
  • to convert the “slice” of array containing our data, we follow the instructions provided for the from_be_bytes() function to use the conversion API try_into().
  • as the serial number is an ASCII string, we must convert the buffer slice corresponding to the value. The conversion is done by the new buffer_to_string function of our program.
  • the unwrap() function extracts the value encapsulated into a Result or Option enum. If the value is present, it is returned, else, the function will panic and stop the program.

If we try to run the program, we get the expected result:

pi@darkstar:~/usr/src/z-rust $ cargo run minizork.z3
   Compiling z-rust v0.1.0 (/home/pi/usr/src/z-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 2.17s
     Running `target/debug/z-rust`
minizork.z3: Infocom (Z-machine 3, Release 34, Serial 871124)

What have we learnt from this step?

Rust conceptDescription
ownershipRust memory management is based on ownership, which avoids the need of garbage collection but implies a good comprehension of the underlying mechanics
if and conditionsThe if keyword is a classic C-Type one, but parenthesis are not needed around the expression
mutabilityMutable variables are defined with the let mut keywords. Moreover Pass-By-Reference mutability involves an explicit &mut before the call parameter
type castingType casting is done with the as keyword
arrays and slicesAn array is a fixed-size collection of objects. Slices are similar but have no known size at compile time
slice rangeThe array[start..end] syntax allows to create a slice from a range of indices
unwrap()Extracts a value encaspulated in a Result or Enum, panics in case of error

Step 5: Z-String decoding: Zork meets Rust :)

As our last step on this project, I would like to display some strings from the game file. But there’s one subtlety to enhance our motivation: each sentence, word, text element of the game is encoded using Z-Strings.

Z-Strings

The Z-String format brings 2 benefits to the Z-machine: string compression and obfuscation (the user won’t be able to parse games files to find puzzle answers).

Here are the main principles, as stated in the specs:

  • A Z-String is a sequence of 16 bits words decomposed in 3 5-bits characters and a “stop” bit indicating the end of the string.
  • 3 alphabets are used: Lowercase / Uppercase / Punctuation (The starting alphabet is Lowercase)
  • Characters 1 to 3 indicate the use of an abbreviation table (abbreviation tables are 32 entries tables of Z-Strings addresses indexed by their position)
  • Characters 4 and 5 indicate an alphabet change: 4 => Uppercase / 5 => Punctuation
  • Character 6 of the punctuation won’t be processed in our implementation
  • Other characters are indices in the current alphabet

We will need to introduce some other Rust concepts in order to process Z-Strings.

Rust structs

To simulate the current VM state, we will use a struct to keep track of its internals.

4 elements need to be stored in our MachineState struct:

  • The memory of the machine (a pointer to the file buffer)
  • The abbreviation table address (as an offset in the memory)
  • The current Alphabet to use (based on an Alphabet enum of 3 entries)
  • The abbreviation table number (1,2 or 3)

The declaration is quite straightforward, with a subtlety:

struct MachineState<'a> {
    memory: &'a [u8],
    abbrev_table_address: u16,
    alphabet: Alphabet,
    abbrev_table: u8,
}

Notice the <'a> after the name of the struct and the corresponding &'a in the memory declaration, they declare a lifetime.

Lifetime

A lifetime is a construct the compiler uses to ensure all borrows are valid (cf. Documentation on Lifetimes in Rust).

Here the <'a> refers to the &'a in the memory pointer definition, giving the compiler the information that the MachineState struct will share the same lifetime as the memory reference.

Constants and Z-String Alphabets

To define the content of our 3 alphabets, we’ll use the const keyword, for instance:

const ALPHA_L: &str = " .....abcdefghijklmnopqrstuvwxyz";

Our 3 alphabets are defined as pointers on primitive str types (string slices). As the first characters of each alphabets have special meaning, they are replaced with dots (except for the first one which is a space in each alphabet).

When the binary processing of the string has been done, the result of our functions is returned as a String struct which provides more flexibility and allows concatenation or growing of the string. As such we use the to_string() and String::from() functions to convert between the str primitive type and the String struct.

Binary operations

We will use binary operations in many steps of our implementation. The main case is the processing of the current 16 bit word to be split in:

  • 3 5-bits character
  • 1 stop bit

The operations are similar as those used in C. The stop bit is processed as follows:

// Loop while the stop bit is not enabled
while word & 0x8000 == 0 {

No surprise. The 3 character split is more complex but easily implemented with binary position shifting (>>) and masking (&).

// Split the 3 5 bit characters
zchar1 = ((word >> 10) & 0b0001_1111) as u8;
zchar2 = ((word >> 5) & 0b0001_1111) as u8;
zchar3 = (word & 0b0001_1111) as u8;

usize

In the Z-String decoding code, you will find references to usize. Usize is a primitive type of Rust used to reference any location in memory. Its size depends on the target physical architecture (4 bytes for a 32 bit target, 8 bytes for a 64 bit target).

Moreover the usize type must be used for arrays indexing, as such you will find many as usize type conversion when using arrays or slices:

Alphabet::L => return_string = (ALPHA_L.as_bytes()[zchar as usize] as char).to_string(),

The implementation

The following implementation is not complete and I won’t detail the Z-String decoding algorithm, I’m only looking for a proof of concept. The offsets provided in the print_file_info() function have been chosen to get some specific strings from the file.

use std::convert::TryInto;
use std::env;
use std::fs;
use std::fs::File;
use std::io::Read;

enum Alphabet {
    // Lowercase
    L,
    // Uppercase
    U,
    // Punctuation
    P,
}

const ALPHA_L: &str = " .....abcdefghijklmnopqrstuvwxyz";
const ALPHA_U: &str = " .....ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const ALPHA_P: &str = " ......\n0123456789.,!?_#'\"/\\-:()"; // "I add this commented quote to preserve syntax highlighting :)

struct MachineState<'a> {
    memory: &'a [u8],
    abbrev_table_address: u16,
    alphabet: Alphabet,
    abbrev_table: u8,
}

fn main() {
    let first_arg = env::args().nth(1);

    match first_arg {
        Some(file_name) => {
            let res = print_file_info(file_name);
            if res.is_err() {
                println!("Error reading file: {:?}", res.err());
            }
        }
        None => println!("No file provided"),
    }
}

fn print_file_info(file_name: String) -> std::io::Result<()> {
    // Read file metadata
    let size = fs::metadata(file_name.clone())?.len();

    if size > 24 {
        // Read 24 bytes of the file to get header infos
        let mut f = File::open(file_name.clone())?;
        let mut buffer = [0 as u8; 0xffff];
        f.read(&mut buffer)?;

        let machine_version = buffer[0];

        // Release
        let release = u16::from_be_bytes(buffer[2..4].try_into().unwrap());
        let serial = buffer_to_string(&buffer[18..24]);

        // Display the infos
        println!(
            "{}: Infocom (Z-machine {}, Release {}, Serial {})",
            file_name, machine_version, release, serial
        );

        // Init abbreviation table
        let abbrev_table_address = u16::from_be_bytes(buffer[0x18..0x1A].try_into().unwrap());

        let mut machine_state = MachineState {
            memory: &buffer,
            abbrev_table_address,
            alphabet: Alphabet::L,
            abbrev_table: 0,
        };

        print!("\n{}", zstring_to_ascii(&mut machine_state, 0x5866));
        println!("{}", zstring_to_ascii(&mut machine_state, 0xc120));
        print!(
            "{}{} /",
            zstring_to_ascii(&mut machine_state, 0x587A),
            release
        );

        println!("{}{}", zstring_to_ascii(&mut machine_state, 0x58e0), serial);
        println!("\n{}", zstring_to_ascii(&mut machine_state, 0x0e08));

        print!("{}", zstring_to_ascii(&mut machine_state, 0x8528));
        println!("{}", zstring_to_ascii(&mut machine_state, 0x8571));

        print!("{}", zstring_to_ascii(&mut machine_state, 0x6c6f));
        print!("{}", zstring_to_ascii(&mut machine_state, 0x187f));
        println!("{}", zstring_to_ascii(&mut machine_state, 0x7734));
        println!("\n>");
    }
    Ok(())
}

fn buffer_to_string(b: &[u8]) -> String {
    std::str::from_utf8(&b).unwrap().to_string()
}

fn zstring_to_ascii(state: &mut MachineState, offset: u16) -> String {
    let mut word: u16 = 0;
    let mut index = offset as usize;
    let mut zchar1: u8;
    let mut zchar2: u8;
    let mut zchar3: u8;
    let mut result_string = String::from("");

    state.alphabet = Alphabet::L;
    state.abbrev_table = 0;
    // Loop while the stop bit is not enabled
    while word & 0x8000 == 0 {
        word = (u16::from(state.memory[index]) << 8) | u16::from(state.memory[index + 1]);
        // Split the 3 5 bit characters
        zchar1 = ((word >> 10) & 0b0001_1111) as u8;
        zchar2 = ((word >> 5) & 0b0001_1111) as u8;
        zchar3 = (word & 0b0001_1111) as u8;

        result_string.push_str(&zchar_to_ascii(state, zchar1).to_owned());
        result_string.push_str(&zchar_to_ascii(state, zchar2).to_owned());
        result_string.push_str(&zchar_to_ascii(state, zchar3).to_owned());

        index += 2;
    }

    result_string
}

fn zchar_to_ascii(state: &mut MachineState, zchar: u8) -> String {
    let abbrev_table_address = state.abbrev_table_address;
    let b = state.memory;

    if state.abbrev_table != 0 {
        let result_abbrev = zstring_to_ascii(
            state,
            u16::from(
                b[(abbrev_table_address
                    + ((state.abbrev_table - 1) * 64) as u16
                    + (zchar * 2) as u16) as usize],
            ) << 8
                | u16::from(
                    b[(abbrev_table_address
                        + ((state.abbrev_table - 1) * 64) as u16
                        + (zchar * 2) as u16
                        + 1) as usize],
                ) * 2,
        );
        state.alphabet = Alphabet::L;
        String::from(result_abbrev)
    } else if zchar >= 1 && zchar <= 3 {
        state.alphabet = Alphabet::L;
        // Current zchar = Number of the 32 entries abbreviation table (0, 32, 64)
        // Next zchar = number of the abbrevation
        state.abbrev_table = zchar;
        String::from("")
    } else if zchar == 4 {
        state.alphabet = Alphabet::U;
        String::from("")
    } else if zchar == 5 {
        state.alphabet = Alphabet::P;
        String::from("")
    } else {
        let return_string;
        match state.alphabet {
            Alphabet::L => return_string = (ALPHA_L.as_bytes()[zchar as usize] as char).to_string(),
            Alphabet::U => return_string = (ALPHA_U.as_bytes()[zchar as usize] as char).to_string(),
            Alphabet::P => return_string = (ALPHA_P.as_bytes()[zchar as usize] as char).to_string(),
        }
        state.alphabet = Alphabet::L;
        return_string
    }
}

And if you launch the program, you should get the following screen:

minizork.z3: Infocom (Z-machine 3, Release 34, Serial 871124)

MINI-ZORK I: The Great Underground Empire
Copyright (c) 1988 Infocom, Inc. All rights reserved.
ZORK is a registered trademark of Infocom, Inc.
Release 34 / Serial number 871124

West of House
You are standing in an open field west of a white house, with a boarded front door. You could circle the house to the north or south.
There is a small mailbox here.

>

It brings back memories … We met our objectives for the scope of this post: getting a glimpse of the language while “having fun” :)

What have we learnt from this step?

Rust conceptDescription
struct, enum and constDefinition of structures, enumerations and constants
binary operationsC type binary operations are supported in Rust
usizeThe usize type is used for pointers and index in arrays/slices
to_string()The to_string() function is available on many types to convert to a displayable String structure
lifetimeThe lifetime is a construct the compiler uses to ensure all borrows are valid, annotations are used to link structure lifetime to encapsulated variables

Conclusion

We just did a scratch on the surface of Rust programming (we didn’t look at dependency management, modularity, …), but it can be enough to give us a feeling for the language.

To sum up:

  • The tooling (cargo) is the perfect entrypoint to the language
  • The compiler tries to help us to solve our errors (even with the naming)
  • Rust provides a large and well-documented API
  • Rust memory management is different from other languages: you need to get a good comprehension of concepts as “borrowing” and “lifetime”

I really like the language and the fact that it deals with dependency management as part of its tooling (something that has been missing from Golang for too long). But while the way the language manages resources is smart and efficient for the compiler, it might be hard to learn for the developer. That’s the price for performance, safety and concurrency.

Written by

Gerben Castel

Father of 3, Software Architect, forever developer and tech enthusiast