ot-tools-io 0.11.3

A library crate for reading/writing binary data files used by the Elektron Octatrack DPS-1.
Documentation
/*
SPDX-License-Identifier: GPL-3.0-or-later
Copyright © 2026 Mike Robeson [dijksterhuis]
*/

//! Types for arrangement data (`arr??.*` files).

mod file;
mod row;
mod state;

/// Error variants for all arrangement types
#[derive(Debug, thiserror::Error)]
pub enum ArrangementError {
    #[error("invalid arrangement anme, must be valid ascii characters: {0:?}")]
    Name([u8; 15]),
    #[error("row type must be 0 (Pattern) / 1 (LoopOrHaltOrJump) / 2 (Reminder): {0:?}")]
    RowType(u8),
    #[error("scene value must be <= 15 or 255: {0:?}")]
    Scene(u8),
    #[error("all midi transpose values must be < 48 || > 207: {0:?}")]
    MidiTranspose([u8; 8]),
    #[error("number of bytes should be 11,336: {0:?}")]
    ByteLength(usize),
    #[error("number of rows doesn't match: n_rows={n_rows:?} first_empty_idx={first_empty_idx:?}")]
    RowCount { n_rows: u8, first_empty_idx: u8 },
    #[error("saved states must be boolean values: {0:?}")]
    SavedStates([u8; 8]),
    #[error("save state must be boolean value: {0:?}")]
    SaveState(u8),
    #[error("loop count cannot exceed 100 (99x): {0:?}")]
    LoopCount(u8),
    #[error("repetitions cannot exceed 63 (64x): {0:?}")]
    RepetitionCount(u8),
    #[error("reminder string must have 15 character length: {0:?}")]
    ReminderStrLen(String),
    #[error("reminder bytes contains non-ascii values: {0:?}")]
    ReminderAscii([u8; 15]),
}

pub use file::ArrangementFile;
use itertools::Itertools;
pub use row::{ArrangeRow, LoopOrJumpOrHaltRow, PatternRow, ReminderRow};
pub use state::ArrangementState;

fn is_not_valid_ascii_byte(x: u8) -> bool {
    !(32..=126).contains(&x)
}

fn bytes_to_ascii_string(bytes: &[u8]) -> String {
    let mut str_data = bytes.to_vec();
    let first_invalid = str_data
        .iter()
        .find_position(|x| is_not_valid_ascii_byte(**x));

    if let Some((x, _)) = first_invalid {
        str_data = str_data[..x].to_vec();
    };

    // this should never fail as we will have removed any invalid characters
    // already
    let s = std::str::from_utf8(&str_data).unwrap().to_ascii_uppercase();

    s
}

/// Arrangement file header data
/// ```text
/// ASCII: FORM....DPS1ARRA........
/// Hex: 46 4f 52 4d 00 00 00 00 44 50 53 31 41 52 52 41 00 00 00 00 00 06
/// U8: [70, 79, 82, 77, 0, 0, 0, 0, 68, 80, 83, 49, 65, 82, 82, 65, 0, 0, 0, 0, 0, 6]
/// ```
pub const ARRANGEMENT_FILE_HEADER: [u8; 21] = [
    70, 79, 82, 77, 0, 0, 0, 0, 68, 80, 83, 49, 65, 82, 82, 65, 0, 0, 0, 0, 0,
];

/// Current/supported version of arrangements files.
pub const ARRANGEMENT_FILE_VERSION: u8 = 6;

/// `"OT_TOOLS_ARR` -- this is a custom name specifically created for ot-tools.
/// The octatrack will normally copy the name of a previously created arrangement
/// when creating arrangements on project creation. Not sure why, but it means
/// arrangements never have a single default name.
const ARRANGEMENT_DEFAULT_NAME: [u8; 15] =
    [79, 67, 84, 65, 84, 79, 79, 76, 83, 45, 65, 82, 82, 32, 32];

#[cfg(test)]
mod mocks {
    use super::{
        ArrangementFile, ArrangementState, ARRANGEMENT_FILE_HEADER, ARRANGEMENT_FILE_VERSION,
    };
    use crate::generics::ArrangeRows;
    use crate::HasChecksumField;

    pub(super) fn mock_arrangement_file_empty_rows() -> (ArrangementFile, [u8; 11336]) {
        let (block_type, block_bytes) = mock_arrangement_block_empty_rows();

        let mut arr = ArrangementFile {
            working_state: block_type,
            stored_state: block_type,
            saved_state: 1,
            other_saved_states: [1, 1, 1, 1, 1, 1, 1, 1],
            ..Default::default()
        };
        arr.update_checksum().unwrap();

        let mut bytes = vec![];
        bytes.append(&mut ARRANGEMENT_FILE_HEADER.to_vec());
        bytes.append(&mut vec![ARRANGEMENT_FILE_VERSION]);
        bytes.append(&mut [0, 0].to_vec());
        bytes.append(&mut block_bytes.to_vec());
        bytes.append(&mut [0, 1].to_vec());
        bytes.append(&mut block_bytes.to_vec());
        bytes.append(&mut [1, 1, 1, 1, 1, 1, 1, 1].to_vec());
        bytes.append(&mut arr.checksum.to_be_bytes().to_vec());

        (arr, *bytes.as_array::<11336>().unwrap())
    }

    pub(super) fn mock_arrangement_block_empty_rows() -> (ArrangementState, [u8; 5650]) {
        let expected = ArrangementState {
            name: [48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48],
            unknown_1: [10, 9],
            n_rows: 0,
            rows: ArrangeRows::default(),
        };

        let bytes: [u8; 5650] = std::array::from_fn(|x| {
            // name
            if x <= 14 {
                48
            } else {
                match x {
                    // unk1 start
                    15 => 10,
                    // unk1 end
                    16 => 9,
                    // n rows
                    17 => 0,
                    _ => 0,
                }
            }
        });
        (expected, bytes)
    }
}