Skip to main content

nord_format/formats/ne5/program/
mod.rs

1//! The Electro 5 program format (`.ne5p`).
2//!
3//! Reads top-down: the format's constants, then [`Program`] — the 121 bytes after
4//! the container header — then the read that pairs it with a header. A file is a
5//! `Cbin<Program>`, which derefs to the body. Each panel is a nested `#[bitbody]`
6//! in its own module, placed here by byte range; the registry paths
7//! (`center_panel.transpose`) follow the field names.
8//!
9//! The live buffer ([`crate::formats::ne5::live`]) is this same body under the tag
10//! `ne5l`, addressed in three slots instead of eight banks of fifty; the two
11//! modules share [`Program`] and differ only in tag and slot space.
12
13mod center;
14mod effects;
15mod organ;
16mod panel;
17mod piano;
18mod sample;
19
20pub use center::{CenterPanel, OrganType};
21pub use effects::{EffectsPanel, EqualizerPart, Fx1Type, Fx2Type, Fx3Type, Fx5Type, Routing};
22pub use organ::{B3PercSpeed, B3Vib, Drawbars, FarfisaVib, OrganModel, OrganPanel, Preset, VoxVib};
23pub use panel::PANEL;
24pub use piano::{PianoCategory, PianoPanel};
25pub use sample::SamplePanel;
26
27pub use crate::fields::Field;
28
29use crate::bank;
30use crate::cbin::{self, Cbin, Header};
31use crate::error::{Error, ParseError};
32use crate::types::RangedU16Pair;
33
34use std::io::{Read, Seek};
35
36pub const FORMAT: &str = "ne5p";
37/// Schema versions this build's field offsets have been validated against. Every corpus
38/// program reports 4. See [`crate::error::ParseError::UnsupportedVersion`].
39pub const KNOWN_VERSIONS: &[u32] = &[4];
40/// What a newly authored program or live slot is written as, in the header and in the
41/// body's echo of it; a file read from disk carries whatever version it held.
42pub const DEFAULT_VERSION: u32 = 4;
43/// The panel body after the container header.
44pub const BODY_LEN: usize = 121;
45/// Type-1 file length: 44-byte CBIN header + the body. A type-0 file is 18 bytes
46/// shorter — 24-byte header, same body, 2-byte trailing checksum. Inferred from
47/// specimens; not confirmed on hardware.
48pub const FILE_LEN: usize = 0x2c + BODY_LEN;
49pub const BANK_COUNT: u16 = 8;
50pub const SLOT_COUNT: u16 = 50;
51
52pub type Location = RangedU16Pair<BANK_COUNT, SLOT_COUNT>;
53pub type Bank = bank::Bank<Cbin<Program>, Location>;
54
55/// The 121-byte panel body: five panels behind a version echo. The pads between
56/// the panels are unclaimed bits, kept verbatim.
57///
58/// Reads and writes byte-exactly. A read verifies the container checksum, gates
59/// on [`KNOWN_VERSIONS`] and the aux word, validates the slot, and range-checks
60/// every field. Placements are pinned by a change-one-knob specimen corpus
61/// written by the instrument; each panel marks its own placements' provenance.
62#[nord_bits_derive::bitbody(121)]
63pub struct Program {
64    /// Every specimen echoes the header's schema version.
65    #[bits(0..=15)]
66    pub program_version: u16,
67
68    #[at(0x02..0x09)]
69    pub center_panel: CenterPanel,
70
71    #[at(0x0e..0x16)]
72    pub piano_panel: PianoPanel,
73
74    #[at(0x1a..0x22)]
75    pub sample_panel: SamplePanel,
76
77    #[at(0x22..0x67)]
78    pub organ_panel: OrganPanel,
79
80    #[at(0x67..0x79)]
81    pub effects_panel: EffectsPanel,
82}
83
84impl Default for Program {
85    fn default() -> Program {
86        Program {
87            raw: [0; BODY_LEN],
88            program_version: DEFAULT_VERSION as u16,
89            center_panel: CenterPanel::default(),
90            piano_panel: PianoPanel::default(),
91            sample_panel: SamplePanel::default(),
92            organ_panel: OrganPanel::default(),
93            effects_panel: EffectsPanel::default(),
94        }
95    }
96}
97
98pub(crate) use crate::formats::known_version;
99
100/// Gate a read on the `aux` word every slot-addressed specimen holds.
101///
102/// Inferred from specimens; not confirmed on hardware. Every slot-addressed file in
103/// the corpus carries `0xFFFFFFFF` at `0x10`. Another value there means the word
104/// carries something this build does not model, so the file is refused rather than
105/// decoded on the assumption it does not matter. ⚠️ Library formats (`nsmp`) use the
106/// word for real data and must not be gated on it.
107pub(crate) fn unset_aux(format: &'static str, header: &Header) -> Result<(), Error> {
108    if header.aux != 0xFFFF_FFFF {
109        return Err(ParseError::AssertFail(format!(
110            "{format}: aux is {:#010x}, not the 0xffffffff every slot-addressed file holds",
111            header.aux,
112        ))
113        .into());
114    }
115    Ok(())
116}
117
118/// The typed slot a header's raw location holds, refused if out of `L`'s slot space.
119pub(crate) fn slot<L: bank::Location>(header: &Header) -> Result<L, Error> {
120    let (bank, slot) = header.slot();
121    (bank, slot)
122        .try_into()
123        .map_err(|_| ParseError::AssertFail(format!("invalid location: {bank} {slot}")).into())
124}
125
126/// The program slot the file claims.
127///
128/// ⚠️ A live slot is the same body under another tag, so this reads a `ne5l` file's
129/// location in the *program* slot space. [`crate::formats::ne5::live::location`] is the
130/// one that answers for a live buffer.
131pub fn location(file: &Cbin<Program>) -> Result<Location, Error> {
132    slot(&file.header)
133}
134
135/// A default program addressed to `location`.
136pub fn new(location: Location) -> Cbin<Program> {
137    Cbin {
138        header: Header::new(FORMAT, location.inner(), DEFAULT_VERSION),
139        body: Program::default(),
140    }
141}
142
143pub fn read_from(reader: &mut (impl Read + Seek)) -> Result<Cbin<Program>, Error> {
144    let file: Cbin<Program> = cbin::read(reader, FORMAT)?;
145    known_version(FORMAT, file.header.version, KNOWN_VERSIONS)?;
146    unset_aux(FORMAT, &file.header)?;
147    location(&file)?;
148    Ok(file)
149}
150
151/// ⚠️ Programs and live slots are one type, so a `ne5l` file placed in a program
152/// [`Bank`] lands wherever its live slot number falls in the program space. The tag
153/// in the header is what tells the two apart.
154impl bank::Item<Location> for Cbin<Program> {
155    fn location(&self) -> Location {
156        // Validated at `read_from` and `new`, and only `Header::set_slot` writes it.
157        location(self).expect("a program's location is validated at construction")
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::cbin::Generation;
165    use std::io::Cursor;
166
167    /// An unknown schema version is refused at read, not decoded on a guess.
168    ///
169    /// Field offsets are only validated for the versions in the corpus. A future
170    /// firmware bumping `ne5p` to 5 could move fields; decoding it with version-4
171    /// offsets would yield plausible but wrong values, and writing it back would then
172    /// persist them. Refusing is the only safe default.
173    #[test]
174    fn an_unknown_schema_version_is_refused() {
175        let program = new((0, 0).try_into().unwrap());
176        let mut bytes = Vec::new();
177        program.write_to(&mut Cursor::new(&mut bytes)).unwrap();
178        assert_eq!(bytes.len(), FILE_LEN);
179
180        // Sanity: as written, it reads back.
181        assert!(read_from(&mut Cursor::new(&mut bytes.clone())).is_ok());
182
183        // The schema version lives at 0x14, little-endian.
184        assert_eq!(u32::from_le_bytes(bytes[0x14..0x18].try_into().unwrap()), 4);
185        bytes[0x14..0x18].copy_from_slice(&5u32.to_le_bytes());
186
187        let err = read_from(&mut Cursor::new(&mut bytes)).expect_err("version 5 must not decode");
188        // The refusal is a matchable variant carrying the facts, not a string.
189        assert!(
190            matches!(
191                err,
192                Error::Parse(crate::error::ParseError::UnsupportedVersion {
193                    format: "ne5p",
194                    version: 5,
195                    ..
196                })
197            ),
198            "unhelpful error: {err}",
199        );
200    }
201
202    /// A header whose `aux` word is not `0xFFFFFFFF` is refused, not decoded past.
203    #[test]
204    fn an_unexpected_aux_word_is_refused() {
205        let program = new((0, 0).try_into().unwrap());
206        let mut bytes = Vec::new();
207        program.write_to(&mut Cursor::new(&mut bytes)).unwrap();
208
209        // The type-1 crc32 covers the body alone, so a header edit needs no restamp.
210        bytes[0x10..0x14].copy_from_slice(&0u32.to_le_bytes());
211        let err = read_from(&mut Cursor::new(&bytes)).expect_err("a set aux must not decode");
212        assert!(
213            matches!(err, Error::Parse(ParseError::AssertFail(_))),
214            "refused for the wrong reason: {err}",
215        );
216    }
217
218    /// Every panel's encode is `From`, not `TryFrom`: no field can overrun its slot.
219    ///
220    /// The other half of that guarantee is not assertable from a test — giving a field a
221    /// type wider than its slot is a const-eval panic out of `Field::FITS`, so retyping
222    /// `PianoPanel::mono` from `bool` to `u8` fails to build rather than failing here.
223    #[test]
224    fn every_panels_encode_is_total() {
225        fn total<P, W>(_: &P)
226        where
227            for<'a> W: From<&'a P>,
228        {
229        }
230
231        let program = new((0, 0).try_into().unwrap());
232        total::<_, [u8; 7]>(&program.center_panel);
233        total::<_, [u8; 8]>(&program.piano_panel);
234        total::<_, [u8; 8]>(&program.sample_panel);
235        total::<_, [u8; 69]>(&program.organ_panel);
236        total::<_, [u8; 18]>(&program.effects_panel);
237    }
238
239    /// Re-stamp the body CRC after corrupting a byte, so a decode test exercises the
240    /// field check rather than the checksum.
241    fn restamp_crc(bytes: &mut [u8]) {
242        let crc = crate::crc::crc32(&bytes[0x2c..]);
243        bytes[0x18..0x1c].copy_from_slice(&crc.to_le_bytes());
244    }
245
246    /// Validation is part of the read, not a step a caller has to remember.
247    ///
248    /// The fallible decode runs inside `cbin::read`'s body pass, so every path to a
249    /// `Program` body validates. Note there is no way to build the corrupt input through
250    /// the API at all: `lower_part` is an `Instrument`, so a panel in memory *cannot*
251    /// hold the invalid value. It has to be forged in the bytes.
252    #[test]
253    fn no_decode_path_can_skip_validation() {
254        let program = new((0, 0).try_into().unwrap());
255        let mut bytes = Vec::new();
256        program.write_to(&mut Cursor::new(&mut bytes)).unwrap();
257
258        // Self-check: re-stamping an untouched file must be a no-op.
259        let pristine = bytes.clone();
260        restamp_crc(&mut bytes);
261        assert_eq!(bytes, pristine, "the CRC helper does not match the writer");
262
263        // 0b111 is not an `Instrument`.
264        bytes[0x2e] |= 0b1110_0000;
265        restamp_crc(&mut bytes);
266
267        let front = read_from(&mut Cursor::new(&mut bytes))
268            .expect_err("the front door accepted an undecodable panel");
269        // Structural, not textual: the typed refusal must survive the read's wrapping.
270        assert!(
271            matches!(
272                front,
273                Error::Parse(crate::error::ParseError::OutOfBounds { .. })
274            ),
275            "refused for the wrong reason: {front}",
276        );
277        assert!(
278            CenterPanel::try_from(<[u8; 7]>::try_from(&bytes[0x2e..0x35]).unwrap()).is_err(),
279            "the conversion itself accepted an undecodable panel",
280        );
281    }
282
283    /// A field set by name lands in the bits that field owns, and in no others.
284    #[test]
285    fn setting_a_field_by_name_moves_only_that_fields_bytes() {
286        let mut program = new((0, 0).try_into().unwrap());
287        let mut before = Vec::new();
288        program.write_to(&mut Cursor::new(&mut before)).unwrap();
289
290        program.set_field("center_panel.transpose", "-5").unwrap();
291        assert_eq!(program.center_panel.transpose.inner(), -5);
292
293        let mut after = Vec::new();
294        program.write_to(&mut Cursor::new(&mut after)).unwrap();
295
296        // `transpose` is bits 24..=27 of a panel starting at 0x2e, so byte 0x31 — plus
297        // the body CRC at 0x18..0x1c, which every body change moves.
298        let moved: Vec<usize> = (0..before.len())
299            .filter(|&i| before[i] != after[i])
300            .collect();
301        assert_eq!(moved, vec![0x18, 0x19, 0x1a, 0x1b, 0x31], "{moved:x?}");
302    }
303
304    /// The library's field names are the CLI's arguments, so a path that does not exist
305    /// has to say which half was wrong.
306    #[test]
307    fn an_unknown_path_names_what_it_could_not_find() {
308        let mut program = new((0, 0).try_into().unwrap());
309        for (path, wanted) in [
310            ("center_panel.nonesuch", "nonesuch"),
311            ("nonesuch.transpose", "nonesuch"),
312            ("transpose", "transpose"),
313        ] {
314            let err = program.set_field(path, "0").unwrap_err().to_string();
315            assert!(err.contains(wanted), "{path}: {err}");
316        }
317    }
318
319    /// Every field the panels declare is listed, and each lists a way to spell itself.
320    #[test]
321    fn every_declared_field_is_settable_by_its_listed_name() {
322        let mut program = new((0, 0).try_into().unwrap());
323        let fields = program.fields();
324        assert!(
325            fields.iter().any(|f| f.path == "center_panel.transpose"),
326            "the worked example is missing from the registry",
327        );
328
329        // Round-tripping every field through its own listed spelling is the property
330        // that makes the registry usable: what `--fields` prints is what `--set` takes.
331        for f in fields {
332            let (path, value) = (f.path.clone(), f.value.clone());
333            program
334                .set_field(&path, &value)
335                .unwrap_or_else(|e| panic!("{path} = {value:?}: {e}"));
336        }
337    }
338
339    /// A nine-nibble drawbar block has no named values, so it is spelled by its bits —
340    /// which for this field is also how a reader wants to see it.
341    #[test]
342    fn a_wide_field_is_spelled_by_its_stored_bits() {
343        let mut program = new((0, 0).try_into().unwrap());
344        program
345            .set_field("organ_panel.b3_preset1_drawbars", "0x087654321")
346            .unwrap();
347        assert_eq!(
348            program.organ_panel.drawbars(OrganModel::B3, Preset::One),
349            [0, 8, 7, 6, 5, 4, 3, 2, 1],
350        );
351
352        let listed = program
353            .fields()
354            .into_iter()
355            .find(|f| f.path == "organ_panel.b3_preset1_drawbars")
356            .expect("declared");
357        assert_eq!(listed.value, "0x87654321");
358        assert_eq!(listed.display, "[0, 8, 7, 6, 5, 4, 3, 2, 1]");
359    }
360
361    /// Decode and encode are inverses on any bytes the decoder accepts.
362    #[test]
363    fn decode_and_encode_are_inverse() {
364        for pattern in [0u64, u64::MAX, 0xa5a5_a5a5_a5a5_a5a5, 0x5a5a_5a5a_5a5a_5a5a] {
365            let raw = pattern.to_be_bytes();
366            let panel = PianoPanel::try_from(raw).unwrap();
367            assert_eq!(<[u8; 8]>::from(&panel), raw);
368
369            let panel = SamplePanel::try_from(raw).unwrap();
370            assert_eq!(<[u8; 8]>::from(&panel), raw);
371
372            let raw: [u8; 7] = raw[..7].try_into().unwrap();
373            if let Ok(panel) = CenterPanel::try_from(raw) {
374                assert_eq!(<[u8; 7]>::from(&panel), raw);
375            }
376        }
377    }
378
379    /// The layout the macro publishes is the layout the codec uses: the panels
380    /// sit where the declaration says, and a nested entry chains into the
381    /// panel's own field placements.
382    #[test]
383    fn the_program_body_layout_is_published_as_data() {
384        use crate::layout::BodyLayout;
385
386        let fields = Program::layout();
387        let center = fields
388            .iter()
389            .find(|f| f.path == "center_panel")
390            .expect("declared");
391        assert_eq!((center.lo / 8, (center.hi + 1) / 8), (0x02, 0x09));
392        let nested = center.nested.expect("a panel chains to its own layout");
393        assert!(
394            nested().iter().any(|f| f.path == "transpose"),
395            "the nested layout does not list the panel's fields",
396        );
397
398        // The registry walks the same structure: full paths, panel by panel.
399        let program = new((0, 0).try_into().unwrap());
400        let paths: Vec<String> = program.fields().into_iter().map(|f| f.path).collect();
401        assert!(paths.contains(&"center_panel.transpose".to_string()));
402        assert!(paths.contains(&"piano_panel.id".to_string()));
403        assert!(paths.contains(&"sample_panel.id".to_string()));
404    }
405
406    /// A program re-tagged type 0 is the same 121-byte body behind the shorter
407    /// header, 18 bytes shorter in total, and it round-trips as itself.
408    #[test]
409    fn a_type_0_program_is_the_same_body_18_bytes_earlier() {
410        let mut program = new((3, 7).try_into().unwrap());
411        let mut v1 = Vec::new();
412        program.write_to(&mut Cursor::new(&mut v1)).unwrap();
413
414        program.header.generation = Generation::V0;
415        let mut v0 = Vec::new();
416        program.write_to(&mut Cursor::new(&mut v0)).unwrap();
417
418        assert_eq!(v1.len() - v0.len(), 18);
419        assert_eq!(&v1[0x2c..], &v0[0x18..v0.len() - 2], "bodies differ");
420        assert_eq!(
421            &v1[0x08..0x18],
422            &v0[0x08..0x18],
423            "shared header fields differ"
424        );
425
426        let back = read_from(&mut Cursor::new(&v0)).unwrap();
427        assert_eq!(back.header.generation, Generation::V0);
428        let mut again = Vec::new();
429        back.write_to(&mut Cursor::new(&mut again)).unwrap();
430        assert_eq!(again, v0, "type-0 round trip changed the bytes");
431    }
432}