Saturday, 19 January 2019

Mod player in Rust - part 1

For my next Rust project I want to try something a bit more challenging than the Sudoku solver. I want to write a mod player in Rust
I learned my programming skills by writing demos for the Amiga 500 in 68000 assembler in the early nineties. I knew all about cycle counts, register optimizations and cost of memory access before I knew what a structure was.
Practically All the demos had music which were provided by mod players. At the time I didn't pay much attention to how the mod players were written but given Rust's focus on low level programming I think this would be a good little project for getting deeper into Rust.

Mod format

One of the challenges with the mod format is that there is not one definite mod file format but many different variants. Different programmers developed their own enhanced versions of the format that were backward compatible to some degree. As the hardware improved the format evolved to account for more hardware channels etc.
I am going to focus on the original format with the view of possibly extending it to cover the latter variants.
I have not found any fully authoritative source for the mod file formats but I have used the descriptions at the following links to understand the structure

Because the original format was developed for the Amiga computer it can be useful to have a look at the old hardware reference manual.
I am also using milky tracker to load and play different mod files to check that my interpretations match.

Reading files and using match

Before we can parse the file we need to read it from disk. The Rust standard library std::fs has a helper function fs:::open that reads the given file and returns a vector of u8.
The code for reading the file is below
    let file_data: Vec<u8> = match fs::read(file_name) {
        Err(why) => panic!("Cant open file {}: {}", file_name, why.description()),
        Ok(data) => data
    };
This contains a lot of Rust goodness that is worth spending some time on. The first thing to note is the match syntax used for handling the return value. This match syntax is effectively like C++ switch...case on steroids.
The value following the keyword match is the value being matched. In our case it is the return value from fs:::read that is being used. The curly braces enclose all the arms of the match. An arm consists of the pattern matcher on the left and handler on the right of the =>.
The match must be exhaustive so the arms must cover every possible value that the match could receive. In this case the return value from fs:::read is a Result enum which can have two variants; Err and Ok.
In Rust different instances of enum variants can have data attached to them. The variant Result::Ok has the actual data inside it and the variant Result::Err has the error. The enums with data is very nice concept that can help make the code more concise.
The match itself can be an expression. So the return value of the chosen arm can be assigned directly into a variable. In this case it gets assigned into file_data
The code above represents a fairly common sequence of operations so there is a helper method Result::expect that will return the value in Ok if that is the result and otherwise will panic and print an error message. Using the helper the above code condenses into;
fn read_mod_file(file_name: &str){
    let file_data: Vec<u8> = fs::read(file_name).expect( &format!(  "Cant open file {}", &file_name ) );
}
This is the code I will use but now I understand what it does can can use enumand match in other places.

Extracting data from Vec

With the data loaded into file_data we can start pulling out information from it. I will start with the easiest part; the name of the mod song. The first 20 bytes in all mod files store the song name. The following line copies that into a Rust string
    let song_name = String::from_utf8_lossy(&file_data[0..20]);
The code file_data[0..20] creates a slice of u8 data which is then passed into String::from_utf8_lossy which returns a String. The slice syntax uses the range operator a..b to specify which parts of the vector should be used for the slice.
Strings in Rust are utf8 which is why the conversion is required.
Next, I want to read and prepare the audio samples. Different versions of the mod format can have different numbers of audio samples but this number is not explicitly stated anywhere so the number of samples needs to be worked out indirectly. Many format variants have a format tag at file offset [1080...1084] The code will inspect this tag and use it to determine the number of samples in the file.
This kind of messy deduction needs to be put in its own function. So far the function only differentiates between files with the tag M.K. and those that dont but this is likely to grow as I encounter more files.
fn identify_num_samples(file_data: &Vec<u8>) -> u32 {
    let format_tag = String::from_utf8_lossy(&file_data[1080..1084]);
    match format_tag.as_ref() {
        "M.K." => 31,
        _ => 15
    }
}

Reading audio samples

Now that I know how many audio samples there are I can extract them. I have set up a structure that contain all the information about each sample ( the contents mirror the specs).
pub struct Sample {
    name: String,
    size: u32,
    volume: u8,
    fine_tune: u8,
    repeat_offset: u32,
    repeat_size: u32,
    samples: Vec<i8>,
}
I have created a matching sample constructor that takes a u8 array and uses it to create the sample structure.
impl Sample{
    fn new( sample_info : &[u8] ) -> Sample {
        let sample_name = String::from_utf8_lossy(&sample_info[0..22]);
        let sample_size: u32 = ((sample_info[23] as u32) + (sample_info[22] as u32) * 256) * 2;
        let fine_tune = sample_info[24];
        let volume = sample_info[25];

        let repeat_offset: u32 = (sample_info[27] as u32) + (sample_info[26] as u32) * 256;
        let repeat_size: u32 = (sample_info[29] as u32) + (sample_info[28] as u32) * 256;

        Sample {
            name: String::from(sample_name),
            size: sample_size,
            volume: volume,
            fine_tune: fine_tune,
            repeat_offset: repeat_offset,
            repeat_size: repeat_size,
            samples: Vec::new(),
        }
    }
The only special bits about this is the extraction of sample_sizerepeat_offsetand repeat_size. They are 16 bit values in the file that are stored in a big endian format so we need to convert them into whatever endiannes we happen to be running on.
It is also worth noting that the argument to the constructor is of [u8] and not the entire file_data vector. This stops the code from accidentally reading from regions that it is not meant to.
To create all the samples I iterate over the relevant part over the file and call a sample constructor on each part.
    let mut samples: Vec<Sample> = Vec::new();
    let mut offset : usize = 20=;
    for _sample_num in 0..num_samples {
        samples.push(Sample::new( &file_data[ offset  .. ( offset + 30 ) as usize  ]));
        offset += 30;
    }
The 'magic' numbers in the above code are the length of each sample info block ( 30 bytes ) and the initial offset into the mod file ( 20 bytes ). I fully intend to convert them into consts/variables once I have a better understanding of the format.

Patterns and pattern tables

Mod files store the actual note data in patterns. Each pattern has 64 lines of note data that control sample playing and effects.
Mod files use pattern tables to control the order in which the patterns are played in. So a pattern table [ 0,1,2,1,2,] would mean the first play patterns 0,1,2 and the play 1 and 2 again.
The pattern table info is stored right after the sample data in the mode file. The first byte is the how many patterns long the song is and the second is the restart position. The following 128 bytes are the actual pattern data.
The code for reading the pattern table is
    let num_patterns: u8 = file_data[offset];
    let end_position: u8 = file_data[offset + 1];
    offset += 2;
    let pattern_table: Vec<u8> = file_data[offset..(offset + 128)].to_vec();
    offset += 128;
the above uses [u8].to_vec() to convert the pattern_table slice into a vector.
I still need to read the patterns and the actual sample data which will be the topic for my next post

No comments:

Post a Comment

Rust Game Development

  Ever since I started learning Rust my intention has been to use it for writing games. In many ways it seems like the obvious choice but th...