Learning motivation: discover Rust with Zork
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.
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:
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
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
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 concept | Description |
---|---|
entrypoint | The entrypoint of a rust program is the main() function found in the src/main.rs file. |
modules | To use a module, we must declare it with the use keyword in the file header. |
println! and substitution | Rust provides macros like println! and string substitution is available to display variable content. ({} is used for simple display value substitution, whereas {:?} provides debug mode). |
variables | You 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 processing | Return from functions producing errors can be handled with pattern matching via the match keyword. |
cargo | Rust 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
Some
element 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 concept | Description |
---|---|
program arguments | An iterator on program arguments is available through the args() function of the std::env module. |
iterators | In Rust, iterators provide a nth() function for accessing elements by position, returning an enum with Some/None values |
naming | Rust 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 concept | Description |
---|---|
functions | Functions in Rust are declared with the fn keyword |
return type | The return type of a function is defined by providing a -> in the function signature |
return value | The return value of a function is provided by the final expression of the function, the return keyword is not necessary |
? operator | The ? allows easier error handling by propagating the error to the calling function |
is_err() function | On 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:
Information | Offset (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 thef.read()
function allows pass-by-reference mutation u8
is the primitive type for 8 bit unsigned integers, theas
keyword in the following line casts the0
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 aResult
orOption
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 concept | Description |
---|---|
ownership | Rust memory management is based on ownership, which avoids the need of garbage collection but implies a good comprehension of the underlying mechanics |
if and conditions | The if keyword is a classic C-Type one, but parenthesis are not needed around the expression |
mutability | Mutable variables are defined with the let mut keywords. Moreover Pass-By-Reference mutability involves an explicit &mut before the call parameter |
type casting | Type casting is done with the as keyword |
arrays and slices | An array is a fixed-size collection of objects. Slices are similar but have no known size at compile time |
slice range | The 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 concept | Description |
---|---|
struct, enum and const | Definition of structures, enumerations and constants |
binary operations | C type binary operations are supported in Rust |
usize | The 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 |
lifetime | The 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.