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