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