Skip to main content

nord_format/formats/ne5/
song.rs

1//! The Electro 5 set list format (`.ne5t`).
2//!
3//! A file is a `Cbin<Song>`: the container header carries the slot, the schema version
4//! and the generation, and the 18-byte body carries the four programs the song plays.
5
6use std::io::{Read, Seek};
7
8use crate::bank;
9use crate::cbin::{self, Cbin, Header};
10use crate::error::Error;
11use crate::formats::ne5::program;
12use crate::types::RangedU16Pair;
13
14pub const FORMAT: &str = "ne5t";
15/// Schema versions this build's field offsets have been validated against: 0 is the
16/// eight factory demo songs, 1 is everything user-written.
17pub const KNOWN_VERSIONS: &[u32] = &[0, 1];
18/// The body after the container header: the 8-byte program map and 10 zero bytes.
19pub const BODY_LEN: usize = 18;
20/// Type-1 file length: 44-byte CBIN header + 18-byte body.
21pub const FILE_LEN: usize = 0x2c + BODY_LEN;
22pub const PROGRAM_COUNT: usize = 4;
23pub const BANK_COUNT: u16 = 4;
24pub const SLOT_COUNT: u16 = 50;
25/// What a newly authored song is written as; a song read from a file carries whatever
26/// version that file held.
27pub const DEFAULT_VERSION: u32 = 1;
28
29pub type Location = RangedU16Pair<BANK_COUNT, SLOT_COUNT>;
30pub type Bank = bank::Bank<Cbin<Song>, Location>;
31
32/// The 18-byte body: four 9-bit program references behind a version echo.
33///
34/// Reads and writes byte-exactly. A read verifies the container checksum, gates
35/// on [`KNOWN_VERSIONS`] and the aux word, and validates the slot.
36///
37/// The container header is never transmitted over USB — the device sends only
38/// this body — so the version is echoed into bits the wire side can see. ⚠️ It
39/// must be the *read* version, never a constant: the eight factory demo songs
40/// are version 0, and stamping 1 here silently rewrites them.
41#[nord_bits_derive::bitbody(18)]
42pub struct Song {
43    #[bits(0..=15)]
44    pub version: u16,
45    #[bits(16..=24)]
46    pub a: program::Location,
47    #[bits(25..=33)]
48    pub b: program::Location,
49    #[bits(34..=42)]
50    pub c: program::Location,
51    #[bits(43..=51)]
52    pub d: program::Location,
53}
54
55/// Which of the four programs a song plays — the entries in panel order.
56///
57/// A song holds exactly these four, so naming one is total: [`Song::get`] and
58/// [`Song::set`] cannot be asked for a fifth.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Slot {
61    A,
62    B,
63    C,
64    D,
65}
66
67impl Slot {
68    /// The four entries in panel order.
69    pub const ALL: [Slot; PROGRAM_COUNT] = [Slot::A, Slot::B, Slot::C, Slot::D];
70
71    /// The entry at a zero-based index, or `None` past the fourth.
72    pub fn at(index: usize) -> Option<Slot> {
73        Self::ALL.get(index).copied()
74    }
75}
76
77impl Song {
78    /// The four programs the song plays, in panel order.
79    pub fn programs(&self) -> [program::Location; PROGRAM_COUNT] {
80        [self.a, self.b, self.c, self.d]
81    }
82
83    pub fn get(&self, slot: Slot) -> program::Location {
84        match slot {
85            Slot::A => self.a,
86            Slot::B => self.b,
87            Slot::C => self.c,
88            Slot::D => self.d,
89        }
90    }
91
92    pub fn set(&mut self, slot: Slot, location: program::Location) {
93        *match slot {
94            Slot::A => &mut self.a,
95            Slot::B => &mut self.b,
96            Slot::C => &mut self.c,
97            Slot::D => &mut self.d,
98        } = location;
99    }
100}
101
102/// The set list slot the file claims.
103pub fn location(file: &Cbin<Song>) -> Result<Location, Error> {
104    program::slot(&file.header)
105}
106
107/// A song at `location` playing `programs`, written as schema `version`.
108///
109/// ⚠️ The version is the caller's to state: the header and the body's echo must agree,
110/// and they only do because both are set from this one argument. A version
111/// [`read_from`] would refuse is refused here too, rather than written and then
112/// unreadable.
113pub fn new(
114    location: Location,
115    version: u32,
116    programs: [program::Location; PROGRAM_COUNT],
117) -> Result<Cbin<Song>, Error> {
118    program::known_version(FORMAT, version, KNOWN_VERSIONS)?;
119    let echo = u16::try_from(version).map_err(|_| crate::error::ParseError::OutOfBounds {
120        value: format!("version {version}"),
121        bound: "a version the body's 16-bit echo can hold".into(),
122    })?;
123    let [a, b, c, d] = programs;
124    Ok(Cbin {
125        header: Header::new(FORMAT, location.inner(), version),
126        body: Song {
127            raw: [0; BODY_LEN],
128            version: echo,
129            a,
130            b,
131            c,
132            d,
133        },
134    })
135}
136
137pub fn read_from(reader: &mut (impl Read + Seek)) -> Result<Cbin<Song>, Error> {
138    let file: Cbin<Song> = cbin::read(reader, FORMAT)?;
139    program::known_version(FORMAT, file.header.version, KNOWN_VERSIONS)?;
140    program::unset_aux(FORMAT, &file.header)?;
141    location(&file)?;
142    Ok(file)
143}
144
145impl bank::Item<Location> for Cbin<Song> {
146    fn location(&self) -> Location {
147        // Validated at `read_from` and `new`, and only `Header::set_slot` writes it.
148        location(self).expect("a song's location is validated at construction")
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::bank::Item;
156    use crate::error::Error;
157    use std::io::Cursor;
158
159    fn song_of(programs: [(u16, u16); PROGRAM_COUNT]) -> Result<Cbin<Song>, Error> {
160        let mut at = [program::Location::default(); PROGRAM_COUNT];
161        for (slot, pair) in at.iter_mut().zip(programs) {
162            *slot = pair.try_into()?;
163        }
164        new((0, 1).try_into()?, DEFAULT_VERSION, at)
165    }
166
167    #[test]
168    fn a_songs_four_programs_survive_a_round_trip() -> Result<(), Error> {
169        let song = song_of([(1, 2), (2, 3), (3, 4), (4, 5)])?;
170
171        assert_eq!(song.location(), (0, 1));
172        for (slot, want) in Slot::ALL.into_iter().zip([(1, 2), (2, 3), (3, 4), (4, 5)]) {
173            assert_eq!(song.get(slot), want, "{slot:?}");
174        }
175
176        let mut bytes = Vec::new();
177        song.write_to(&mut Cursor::new(&mut bytes)).unwrap();
178        let back = read_from(&mut Cursor::new(&mut bytes)).unwrap();
179
180        assert_eq!(song.location(), back.location());
181        for slot in Slot::ALL {
182            assert_eq!(song.get(slot), back.get(slot), "{slot:?}");
183        }
184
185        Ok(())
186    }
187
188    /// The body echoes the header's version, and both come from the one argument — so a
189    /// version the read would refuse cannot be written in the first place.
190    #[test]
191    fn a_version_no_read_accepts_is_not_written() -> Result<(), Error> {
192        let at = [program::Location::default(); PROGRAM_COUNT];
193        for version in KNOWN_VERSIONS {
194            assert!(new((0, 0).try_into()?, *version, at).is_ok(), "v{version}");
195        }
196        let err = new((0, 0).try_into()?, 2, at).expect_err("version 2 must not be written");
197        assert!(
198            matches!(
199                err,
200                Error::Parse(crate::error::ParseError::UnsupportedVersion { version: 2, .. })
201            ),
202            "refused for the wrong reason: {err}",
203        );
204        // The echo is 16 bits wide, and `as` would have written 0 for this one.
205        assert!(new((0, 0).try_into()?, 0x1_0000, at).is_err());
206        Ok(())
207    }
208
209    /// A version-0 song must come back out as version 0.
210    ///
211    /// The eight factory demo songs are version 0 and everything user-written is
212    /// version 1. A writer stamping a constant into the header or the map's echo
213    /// silently promotes them — a real difference at offset `0x14` and again in the
214    /// body, on every one of the eight.
215    #[test]
216    fn version_survives_a_round_trip() -> Result<(), Error> {
217        for version in [0u32, 1] {
218            let song = new(
219                (0, 5).try_into()?,
220                version,
221                [
222                    (1, 2).try_into()?,
223                    (2, 3).try_into()?,
224                    (3, 4).try_into()?,
225                    (4, 5).try_into()?,
226                ],
227            )?;
228
229            let mut bytes = Vec::new();
230            song.write_to(&mut Cursor::new(&mut bytes)).unwrap();
231
232            // Header field at 0x14, little-endian.
233            assert_eq!(
234                u32::from_le_bytes(bytes[0x14..0x18].try_into().unwrap()),
235                version,
236                "header version for v{version}",
237            );
238            // ...and the echo in the top bits of the big-endian map word at 0x2c, which
239            // is the only copy the device ever sees.
240            assert_eq!(
241                u16::from_be_bytes(bytes[0x2c..0x2e].try_into().unwrap()) as u32,
242                version,
243                "body version echo for v{version}",
244            );
245
246            let back = read_from(&mut Cursor::new(&mut bytes)).unwrap();
247            assert_eq!(back.header.version, version);
248            assert_eq!(back.get(Slot::A), song.get(Slot::A));
249        }
250        Ok(())
251    }
252
253    /// Writing one entry moves that entry and leaves the other three where they were.
254    #[test]
255    fn setting_one_entry_leaves_the_others_alone() -> Result<(), Error> {
256        let mut song = song_of([(1, 2), (2, 3), (3, 4), (4, 5)])?;
257
258        song.set(Slot::B, (5, 20).try_into()?);
259
260        assert_eq!(song.location(), (0, 1));
261        for (slot, want) in Slot::ALL.into_iter().zip([(1, 2), (5, 20), (3, 4), (4, 5)]) {
262            assert_eq!(song.get(slot), want, "{slot:?}");
263        }
264
265        let mut bytes = Vec::new();
266        song.write_to(&mut Cursor::new(&mut bytes)).unwrap();
267        let back = read_from(&mut Cursor::new(&mut bytes)).unwrap();
268
269        assert_eq!(song.location(), back.location());
270        for slot in Slot::ALL {
271            assert_eq!(song.get(slot), back.get(slot), "{slot:?}");
272        }
273
274        Ok(())
275    }
276
277    /// Four entries, and the index that names them stops there.
278    #[test]
279    fn a_song_holds_four_entries_and_no_fifth() {
280        assert_eq!(Slot::ALL.len(), PROGRAM_COUNT);
281        assert_eq!(Slot::at(0), Some(Slot::A));
282        assert_eq!(Slot::at(PROGRAM_COUNT - 1), Some(Slot::D));
283        assert_eq!(Slot::at(PROGRAM_COUNT), None);
284    }
285}