ot_tools_io/
arrangements.rs

1/*
2SPDX-License-Identifier: GPL-3.0-or-later
3Copyright © 2024 Mike Robeson [dijksterhuis]
4*/
5
6//! Types and ser/de for `arr??.*` binary data files.
7//!
8//! Proper checksum calculations are not yet implemented. But this doesn't seem
9//! to have an impact on loading arrangements onto the Octatrack.
10
11mod deserialize;
12mod serialize;
13
14use crate::{
15    Defaults, HasChecksumField, HasFileVersionField, HasHeaderField, IsDefault, OctatrackFileIO,
16    OtToolsIoError,
17};
18use ot_tools_io_derive::{ArrayDefaults, BoxedBigArrayDefaults, IntegrityChecks};
19use serde::Serialize;
20use serde_big_array::Array;
21use std::array::from_fn;
22
23/// Arrangement file header data
24/// ```text
25/// ASCII: FORM....DPS1ARRA........
26/// Hex: 46 4f 52 4d 00 00 00 00 44 50 53 31 41 52 52 41 00 00 00 00 00 06
27/// U8: [70, 79, 82, 77, 0, 0, 0, 0, 68, 80, 83, 49, 65, 82, 82, 65, 0, 0, 0, 0, 0, 6]
28/// ```
29pub const ARRANGEMENT_FILE_HEADER: [u8; 21] = [
30    70, 79, 82, 77, 0, 0, 0, 0, 68, 80, 83, 49, 65, 82, 82, 65, 0, 0, 0, 0, 0,
31];
32
33/// Current/supported version of arrangements files.
34pub const ARRANGEMENT_FILE_VERSION: u8 = 6;
35
36/// `"OT_TOOLS_ARR` -- this is a custom name specifically created for ot-tools.
37/// The octatrack will normally copy the name of a previously created arrangement
38/// when creating arrangements on project creation. Not sure why, but it means
39/// arrangements never have a single default name.
40const ARRANGEMENT_DEFAULT_NAME: [u8; 15] =
41    [79, 67, 84, 65, 84, 79, 79, 76, 83, 45, 65, 82, 82, 32, 32];
42
43// max length: 11336 bytes
44/// Base model for `arr??.*` arrangement binary data files.
45#[derive(Debug, Clone, Serialize, PartialEq, IntegrityChecks)]
46pub struct ArrangementFile {
47    /// Header data
48    pub header: [u8; 21],
49
50    /// Patch version of this data type. Is inspected on project load to determine if
51    /// the octatrack is able to load a project from current OS, or whether the project
52    /// files need to be patched and updated.
53    pub datatype_version: u8,
54
55    /// Dunno. Example data:
56    /// ```text
57    /// [0, 0]
58    /// ```
59    pub unk1: [u8; 2],
60
61    /// Current arrangement data in active use.
62    /// This block is written when saving via Project Menu -> SYNC TO CARD.
63    ///
64    /// The second block is written when saving the arrangement via Arranger Menu -> SAVE.
65    pub arrangement_state_current: ArrangementBlock,
66
67    /// Dunno.
68    pub unk2: u8,
69
70    /// Whether the arrangement has been saved within the arrangement menu.
71    pub saved_flag: u8,
72
73    /// Arrangement data from the previous saved state.
74    /// This block is written when saving the arrangement via Arranger Menu -> SAVE.
75    pub arrangement_state_previous: ArrangementBlock,
76
77    /// The current save/unsaved state of **all** loaded arrangements.
78    /// 'Saved' means there's been at least one ARRANGER MENU save operation performed,
79    /// and [`ArrangementBlock`] data has been written to the `arrangement_state_previous` field.
80    ///
81    /// Example data:
82    /// ```text
83    /// Arrangement 1 has been saved: [1, 0, 0, 0, 0, 0, 0, 0]
84    /// Arrangement 2 has been saved: [0, 1, 0, 0, 0, 0, 0, 0]
85    /// Arrangement 2, 7 & 8 have been saved: [0, 1, 0, 0, 0, 0, 1, 1]
86    /// ```
87    pub arrangements_saved_state: [u8; 8],
88
89    /// Checksum for the file.
90    pub checksum: u16,
91}
92
93impl OctatrackFileIO for ArrangementFile {
94    fn encode(&self) -> Result<Vec<u8>, OtToolsIoError> {
95        // TODO: oh god it looks like i might need to swap byte order on everything,
96        //       possibly including bank data swapping bytes is one required when
97        //       running on little-endian systems
98        let mut swapped = self.clone();
99        if cfg!(target_endian = "little") {
100            swapped.checksum = self.checksum.swap_bytes();
101        }
102        let bytes = bincode::serialize(&swapped)?;
103        Ok(bytes)
104    }
105}
106
107impl HasChecksumField for ArrangementFile {
108    fn calculate_checksum(&self) -> Result<u16, OtToolsIoError> {
109        Ok(0)
110    }
111    fn check_checksum(&self) -> Result<bool, OtToolsIoError> {
112        Ok(self.checksum == self.calculate_checksum()?)
113    }
114}
115
116impl Default for ArrangementFile {
117    fn default() -> Self {
118        // TODO: Clean up once checksums are dealt with
119        // let init = Self {
120        // let init = Self {
121        //     header: ARRANGEMENT_FILE_HEADER,
122        //     unk1: from_fn(|_| 0),
123        //     arrangement_state_current: ArrangementBlock::default(),
124        //     // TODO
125        //     unk2: 0,
126        //     // TODO
127        //     saved_flag: 1,
128        //     arrangement_state_previous: ArrangementBlock::default(),
129        //     // WARN: by default this actually always 0, but generating test data where these things
130        //     // are 'inactive' is basically impossible!
131        //     // for now, I'm setting this to default a 1-values array, but in reality it depends...
132        //     // cba to type this out fully. gonna bite me later i know it. basically i need to check
133        //     // if arrangement chaining affects this flag or if the arrangement has been saved.
134        //     arrangements_saved_state: from_fn(|_| 1),
135        //     checksum: 1973,
136        // };
137
138        // let bytes = bincode::serialize(&init).unwrap();
139        // let checksum = get_checksum(&bytes);
140        // init.checksum = checksum.unwrap();
141        // init
142
143        Self {
144            header: ARRANGEMENT_FILE_HEADER,
145            datatype_version: ARRANGEMENT_FILE_VERSION,
146            unk1: from_fn(|_| 0),
147            arrangement_state_current: ArrangementBlock::default(),
148            // TODO
149            unk2: 0,
150            // TODO
151            saved_flag: 0,
152            arrangement_state_previous: ArrangementBlock::default(),
153            // WARN: by default this actually always 0, but generating test data where these things
154            // are 'inactive' is basically impossible!
155            // for now, I'm setting this to default a 1-values array, but in reality it depends...
156            // cba to type this out fully. gonna bite me later i know it. basically i need to check
157            // if arrangement chaining affects this flag or if the arrangement has been saved.
158            arrangements_saved_state: from_fn(|_| 1),
159            checksum: 1973,
160        }
161    }
162}
163
164impl HasHeaderField for ArrangementFile {
165    fn check_header(&self) -> Result<bool, OtToolsIoError> {
166        Ok(self.header == ARRANGEMENT_FILE_HEADER)
167    }
168}
169
170impl HasFileVersionField for ArrangementFile {
171    fn check_file_version(&self) -> Result<bool, OtToolsIoError> {
172        Ok(self.datatype_version == ARRANGEMENT_FILE_VERSION)
173    }
174}
175
176impl IsDefault for ArrangementFile {
177    fn is_default(&self) -> bool {
178        let default = &ArrangementFile::default();
179        // check everything except the arrangement name fields (see
180        // ArrangementBlock's IsDefault implementation for more details)
181        self.arrangement_state_current.is_default()
182            && self.arrangement_state_previous.is_default()
183            && default.unk1 == self.unk1
184            && default.unk2 == self.unk2
185    }
186}
187
188/// Base model for an arrangement 'block' within an arrangement binary data file.
189/// There are two arrangement 'blocks' in each arrangement file -- enabling the
190/// arrangement 'reload ' functionality.
191#[derive(Debug, Eq, PartialEq, Clone)]
192pub struct ArrangementBlock {
193    /// Name of the Arrangement in ASCII values, max length 15 characters
194    pub name: [u8; 15], // String,
195
196    /// Unknown data. No idea what this is. Usually `[0, 0]` or `[0, 1]`
197    pub unknown_1: [u8; 2],
198
199    /// Number of active rows in the arrangement. Any parsed row data after this number of rows
200    /// should be an `ArrangeRow::EmptyRow` variant.
201    ///
202    /// #### WARNING
203    /// Max number of `ArrangeRows` (256) is a zero value here! Zero rows are also possible, so do
204    /// not use this field as a guaranteed row count.
205    pub n_rows: u8,
206
207    /// Rows of the arrangement.
208    pub rows: Box<Array<ArrangeRow, 256>>,
209}
210
211impl Default for ArrangementBlock {
212    fn default() -> Self {
213        Self {
214            name: ARRANGEMENT_DEFAULT_NAME,
215            unknown_1: from_fn(|_| 0),
216            n_rows: 0,
217            rows: ArrangeRow::defaults(),
218        }
219    }
220}
221
222impl IsDefault for ArrangementBlock {
223    fn is_default(&self) -> bool {
224        let default = &Self::default();
225
226        // when the octatrack creates a new arrangement file, it will reuse a
227        // name from a previously created arrangement in a different project
228        //
229        // no idea why it does this (copying the other file?) but it does it
230        // reliably when creating a new project from the project menu.
231        default.unknown_1 == self.unknown_1
232            && default.n_rows == self.n_rows
233            && default.rows == self.rows
234    }
235}
236
237/// Base model for an arranger row within an arrangement block.
238#[derive(Debug, PartialEq, Eq, Clone, ArrayDefaults, BoxedBigArrayDefaults)]
239pub enum ArrangeRow {
240    /// pattern choice and playback
241    PatternRow {
242        /// Which Pattern should be played at this point. Patterns are indexed from 0 (A01) -> 256 (P16).
243        pattern_id: u8,
244        /// How many times to play this arrangement row.
245        repetitions: u8,
246        /// How track muting is applied during this arrangement row.
247        mute_mask: u8,
248        /// First part of the Tempo mask for this row.
249        /// Needs to be combined with `tempo_2` to work out the actual tempo (not sure how it works yet).
250        tempo_1: u8,
251        /// Second part of the Tempo mask for this row.
252        /// Needs to be combined with `tempo_1` to work out the actual tempo (not sure how it works yet).
253        tempo_2: u8,
254        /// Which scene is assigned to Scene slot A when this arrangement row is playing.
255        scene_a: u8,
256        /// Which scene is assigned to Scene slot B when this arrangement row is playing.
257        scene_b: u8,
258        /// Which trig to start Playing the pattern on.
259        offset: u8,
260        /// How many trigs to play the pattern for.
261        /// Note that this value always has `offset` added to it.
262        /// So a length on the machine display of 64 when the offset is 32 will result in a value of 96 in the file data.
263        length: u8,
264        /// MIDI Track transposes for all 8 midi channels.
265        /// 1 -> 48 values are positive transpose settings.
266        /// 255 (-1) -> 207 (-48) values are negative transpose settings.
267        midi_transpose: [u8; 8],
268    },
269    /// Loop/Jump/Halt rows are all essentially just loops. Example: Jumps are an infinite loop.
270    /// So these are bundled into one type.
271    ///
272    /// Loops are `loop_count = 0 -> 65` and the `row_target` is any row before this one (`loop_count=0` is infinite looping).
273    /// Halts are `loop_count = 0` and the `row_target` is this row.
274    /// Jumps are `loop_count = 0` and the `row_target` is any row after this one.
275    LoopOrJumpOrHaltRow {
276        /// How many times to loop to the `row_target`. Only applies to loops.
277        loop_count: u8,
278        /// The row number to loop back to, jump to, or end at.
279        row_target: u8,
280    },
281    /// A row of ASCII text data with 15 maximum length.
282    ReminderRow(String),
283    /// Row is not in use. Only used in an `ArrangementBlock` as a placeholder for null basically.
284    EmptyRow(),
285}
286
287impl Default for ArrangeRow {
288    fn default() -> Self {
289        Self::EmptyRow()
290    }
291}
292
293#[cfg(test)]
294mod test {
295    mod integrity_check {
296        use crate::arrangements::ArrangementFile;
297        use crate::{HasHeaderField, OtToolsIoError};
298
299        #[test]
300        fn true_valid_header() -> Result<(), OtToolsIoError> {
301            let arr = ArrangementFile::default();
302            assert!(arr.check_header()?);
303            Ok(())
304        }
305
306        #[test]
307        fn false_invalid_header() -> Result<(), OtToolsIoError> {
308            let mut arr = ArrangementFile::default();
309            arr.header[0] = 0x01;
310            arr.header[1] = 0x01;
311            arr.header[2] = 0x50;
312            assert!(!arr.check_header()?);
313            Ok(())
314        }
315    }
316
317    mod is_default {
318        use crate::arrangements::ArrangementFile;
319        use crate::test_utils::get_arrange_dirpath;
320        use crate::{IsDefault, OctatrackFileIO};
321
322        #[test]
323        fn true_not_modified_default() {
324            assert!(ArrangementFile::default().is_default())
325        }
326        #[test]
327        fn true_not_modified_file() {
328            let arr =
329                ArrangementFile::from_data_file(&get_arrange_dirpath().join("blank.work")).unwrap();
330            assert!(arr.is_default())
331        }
332        #[test]
333        fn false_modified_file() {
334            let arr =
335                ArrangementFile::from_data_file(&get_arrange_dirpath().join("full-options.work"))
336                    .unwrap();
337            assert!(!arr.is_default())
338        }
339    }
340}
341
342#[cfg(test)]
343mod checksum {
344    use crate::arrangements::ArrangementFile;
345    use crate::test_utils::get_arrange_dirpath;
346    use crate::{OctatrackFileIO, OtToolsIoError};
347    use std::path::Path;
348
349    // make sure arrangements we're testing are equal first, ignoring the checksums
350    fn arr_eq_helper(a: &ArrangementFile, b: &ArrangementFile) {
351        assert_eq!(a.header, b.header);
352        assert_eq!(a.datatype_version, b.datatype_version);
353        assert_eq!(a.unk1, b.unk1);
354        assert_eq!(a.unk2, b.unk2);
355        assert_eq!(a.arrangements_saved_state, b.arrangements_saved_state);
356        assert_eq!(a.arrangement_state_current, b.arrangement_state_current);
357        assert_eq!(a.arrangement_state_previous, b.arrangement_state_previous);
358    }
359
360    // :eyes: https://github.com/beeb/octarranger/blob/master/src/js/stores/OctaStore.js#L150-L174
361    // this only seems to work for default / blank patterns (with name changes)
362    // and the 4x patterns test case ...?
363    //
364    // ... which is the same as the get_checksum function below :/
365    //
366    // yeah it's basically the same fucntion. when you factor all the crud out,
367    // it basically turns into an overcomplicated u16 wrapped sum of individual
368    // bytes
369    #[allow(dead_code)]
370    fn get_checksum_octarranger(bytes: &[u8]) -> Result<u16, OtToolsIoError> {
371        let bytes_no_header_no_chk = &bytes[16..bytes.len() - 2];
372
373        let mut chk: u16 = 0;
374        for byte_pair in &bytes_no_header_no_chk
375            .chunks(2)
376            .map(|x| x.to_vec())
377            .filter(|x| x.len() > 1)
378            .collect::<Vec<Vec<u8>>>()
379        {
380            let first_byte = byte_pair[0] as u16;
381            let second_byte = byte_pair[1] as u16;
382            chk = second_byte.wrapping_add(first_byte.wrapping_add(chk));
383        }
384
385        Ok(chk)
386    }
387
388    fn get_checksum_simple(bytes: &[u8]) -> Result<u16, OtToolsIoError> {
389        let bytes_no_header_no_chk = &bytes[16..bytes.len() - 2];
390
391        let mut prev;
392        let mut chk: u32 = 0;
393        for byte in bytes_no_header_no_chk {
394            prev = chk;
395            chk = chk.wrapping_add((*byte as u32).wrapping_add(0));
396            if byte != &0 {
397                println!("chk: {chk} diff: {}", chk - prev);
398            }
399        }
400        println!("CHK32: {chk}");
401        Ok((chk).wrapping_mul(1) as u16)
402    }
403
404    // TODO: Very dirty implementation
405    // not working for arrangements -- looks like these have a different checksum implementation
406    #[allow(dead_code)]
407    fn get_checksum_bank(bytes: &[u8]) -> Result<u16, OtToolsIoError> {
408        let bytes_no_header_no_chk = &bytes[16..bytes.len() - 2];
409        let default_bytes = &bincode::serialize(&ArrangementFile::default())?;
410        let def_important_bytes = &default_bytes[16..bytes.len() - 2];
411        let default_checksum: i32 = 1870;
412        let mut byte_diffs: i32 = 0;
413        for (byte, def_byte) in bytes_no_header_no_chk.iter().zip(def_important_bytes) {
414            let byte_diff = (*byte as i32) - (*def_byte as i32);
415            if byte_diff != 0 {
416                byte_diffs += byte_diff;
417            }
418        }
419        let check = byte_diffs * 256 + default_checksum;
420        let modded = check.rem_euclid(65535);
421        Ok(modded as u16)
422    }
423
424    fn helper_test_chksum(fp: &Path) {
425        let valid = ArrangementFile::from_data_file(fp).unwrap();
426        let mut test = valid.clone();
427        test.checksum = 0;
428        arr_eq_helper(&test, &valid);
429
430        let bytes = bincode::serialize(&test).unwrap();
431        let r = get_checksum_simple(&bytes);
432        assert!(r.is_ok());
433        let res = r.unwrap();
434        let s_attr_chk: u32 = bytes[16..bytes.len() - 2]
435            .iter()
436            .map(|x| *x as u32)
437            .sum::<u32>()
438            .rem_euclid(u16::MAX as u32 + 1);
439
440        let non_zero_bytes = bytes.iter().filter(|b| b > &&0).count();
441        let non_zero_sum = bytes
442            .iter()
443            .cloned()
444            .filter(|b| b > &0)
445            .map(|x| x as u32)
446            .sum::<u32>();
447
448        println!(
449            "l: {} r: {} non_zero_bytes {} sum total: {} s-attr {} diff {} (or {})",
450            res,
451            valid.checksum,
452            non_zero_bytes,
453            non_zero_sum,
454            s_attr_chk,
455            res.wrapping_sub(valid.checksum),
456            valid.checksum.wrapping_sub(res)
457        );
458        println!(
459            "checksum bytes: {:?} target bytes: {:?}",
460            [(res >> 8) as u8, res as u8],
461            [(valid.checksum >> 8) as u8, valid.checksum as u8]
462        );
463        assert_eq!(res, valid.checksum);
464    }
465
466    // works with original sample attrs implementation
467    #[test]
468    fn blank() {
469        helper_test_chksum(&get_arrange_dirpath().join("blank.work"));
470    }
471
472    // works with original sample attrs implementation
473    #[test]
474    fn blank_diffname1() {
475        helper_test_chksum(&get_arrange_dirpath().join("blank-diffname1.work"));
476    }
477
478    // works with original sample attrs implementation
479    #[test]
480    fn blank_diffname2() {
481        helper_test_chksum(&get_arrange_dirpath().join("blank-diffname2.work"));
482    }
483
484    // current diff to sample attrs impl = 142 * 8 (1136)
485    #[test]
486    #[ignore]
487    fn one_rem_row_notext() {
488        helper_test_chksum(&get_arrange_dirpath().join("1-rem-blank-txt.work"));
489    }
490
491    // current difference = 1796
492    // so the CHAIN text adds 660
493    #[test]
494    #[ignore]
495    fn one_rem_row_wtext() {
496        helper_test_chksum(&get_arrange_dirpath().join("1-rem-CHAIN-txt.work"));
497    }
498
499    #[test]
500    #[ignore]
501    fn two_rem_row_wtext() {
502        helper_test_chksum(&get_arrange_dirpath().join("2-rem-CHAIN-txt.work"));
503    }
504
505    #[test]
506    #[ignore]
507    fn four_patterns() {
508        helper_test_chksum(&get_arrange_dirpath().join("4-patterns.work"));
509    }
510
511    #[test]
512    #[ignore]
513    fn one_pattern() {
514        helper_test_chksum(&get_arrange_dirpath().join("1-pattern.work"));
515    }
516
517    #[test]
518    #[ignore]
519    fn one_halt() {
520        helper_test_chksum(&get_arrange_dirpath().join("1-halt.work"));
521        // works for this specific case only
522
523        // let bytes = bincode::serialize(&arr).unwrap();
524        // let bytes_no_header_no_chk = &bytes[16..bytes.len() - 2];
525        //
526        // let mut chk: u16 = 0;
527        // for byte in bytes_no_header_no_chk {
528        //     chk = chk.wrapping_add(*byte as u16);
529        // }
530        // let checksum = chk.wrapping_mul(2).wrapping_add(254);
531        //
532        // println!(
533        //     "checksum bytes: {:?} target bytes: {:?}",
534        //     [(checksum >> 8) as u8, checksum as u8],
535        //     [(valid.checksum >> 8) as u8, valid.checksum as u8]
536        // );
537        // println!(
538        //     "diff: {} (or {})",
539        //     checksum.wrapping_sub(valid.checksum),
540        //     valid.checksum.wrapping_sub(checksum)
541        // );
542        // assert_eq!(checksum, valid.checksum);
543    }
544
545    #[test]
546    #[ignore]
547    fn one_pattern_1_loop() {
548        helper_test_chksum(&get_arrange_dirpath().join("1-patt-1-loop.work"));
549    }
550
551    #[test]
552    #[ignore]
553    fn one_pattern_1_jump_1_loop() {
554        helper_test_chksum(&get_arrange_dirpath().join("1-patt-1-jump-1-loop.work"));
555    }
556
557    #[test]
558    #[ignore]
559    fn one_pattern_1_jump_1_loop_1_halt() {
560        helper_test_chksum(&get_arrange_dirpath().join("1-patt-1-jump-1-loop-1-halt.work"));
561    }
562
563    #[test]
564    #[ignore]
565    fn full_options() {
566        helper_test_chksum(&get_arrange_dirpath().join("full-options.work"));
567    }
568
569    // FIXME: Did i not save/copy this properly? this only has empty rows
570    #[test]
571    #[ignore]
572    fn full_options_no_rems() {
573        helper_test_chksum(&get_arrange_dirpath().join("full-options-no-rems.work"));
574    }
575
576    #[test]
577    fn no_saved_flag() {
578        helper_test_chksum(&get_arrange_dirpath().join("no-saved-flag.work"));
579    }
580
581    #[test]
582    fn with_saved_flag() {
583        helper_test_chksum(&get_arrange_dirpath().join("with-saved-flag.work"));
584    }
585
586    #[test]
587    fn blank_samename_saved() {
588        helper_test_chksum(&get_arrange_dirpath().join("blank-samename-saved-chktest.work"));
589    }
590
591    #[test]
592    fn blank_samename_unsaved() {
593        helper_test_chksum(&get_arrange_dirpath().join("blank-samename-unsaved-chktest.work"));
594    }
595}