Skip to main content

nord_format/formats/ne5/
settings.rs

1//! The Electro 5 global settings format (`.ne5s`).
2//!
3//! Reads top-down: the format's constants, the read that pairs a header with
4//! [`Settings`], then the body itself — the 34 bytes after the header, one flat
5//! `#[bitbody]`. A file is a `Cbin<Settings>`, which derefs to the body.
6//!
7//! The body holds the instrument's System, MIDI and Sound menus. Fields run from bit 38
8//! to bit 141 in no particular menu order — the MIDI channels sit between two System
9//! settings — so the declaration below is grouped the way the instrument's menus are and
10//! the placements do the reordering.
11//!
12//! Every placement: Confirmed on hardware. A capture that changed one setting on the
13//! panel moves exactly the bits that setting's field claims, and nothing else. Where a
14//! field's *range* runs past the values the captures reach, the field says so.
15//!
16//! Bits 0..=15 are the schema version echoed into the body, which every `ne5` format
17//! carries at `0x2c` because the container header is not transmitted over USB — see
18//! [`crate::formats::ne5::song`]. `ne5s` is version 0, so they read zero.
19//!
20//! Bits 16..=37 are the `startup_*` settings below — the selections the instrument
21//! restores at power-up. **Bit 18 is the only bit below the menu settings that no
22//! field claims.** It is clear in every specimen. Whatever it is, it survives a re-encode
23//! untouched, as does everything past the last setting.
24//!
25//! **Two cataloged settings are not stored here.** Toggling *memory protect* and *local
26//! control* on the panel — the change verified on the display — and re-reading the object
27//! moves no bit of the body. Confirmed on hardware. Both live outside this object, so
28//! neither is decoded.
29
30use crate::cbin::{self, Cbin, Header};
31use crate::components::sparse_enum;
32use crate::error::{Error, ParseError};
33use crate::formats::ne5::{program, song};
34use crate::types::{RangedI8, RangedU8};
35use nord_bits_derive::bitbody;
36
37use std::fmt::{self, Debug, Display, Formatter};
38use std::io::{Read, Seek};
39
40pub const FORMAT: &str = "ne5s";
41/// Schema versions validated against the corpus. Every corpus settings file reports 0.
42pub const KNOWN_VERSIONS: &[u32] = &[0];
43/// Length of the settings body block, `0x2c..=0x4d`.
44pub const BODY_LEN: usize = 0x4e - 0x2c;
45/// Type-1 file length: 44-byte CBIN header + 34-byte body.
46pub const FILE_LEN: usize = 0x2c + BODY_LEN;
47
48/// A default settings file.
49///
50/// There is no slot to speak of: the instrument holds exactly one of these, and every
51/// specimen addresses it to bank 0 slot 0.
52pub fn new() -> Cbin<Settings> {
53    Cbin {
54        header: Header::new(FORMAT, (0, 0), 0),
55        body: Settings::default(),
56    }
57}
58
59pub fn read_from(reader: &mut (impl Read + Seek)) -> Result<Cbin<Settings>, Error> {
60    let file: Cbin<Settings> = cbin::read(reader, FORMAT)?;
61    program::known_version(FORMAT, file.header.version, KNOWN_VERSIONS)?;
62    program::unset_aux(FORMAT, &file.header)?;
63    // The instrument holds exactly one settings file, so the location field has
64    // nothing to address; every specimen holds bank 0 slot 0.
65    let (bank, slot) = file.header.slot();
66    if (bank, slot) != (0, 0) {
67        return Err(ParseError::AssertFail(format!(
68            "{FORMAT}: location is {bank} {slot}, and settings live at 0 0"
69        ))
70        .into());
71    }
72    Ok(file)
73}
74
75/// Half-step global transposition, `-6..=6`, stored biased by 6.
76pub type GlobalTranspose = RangedI8<6, -6, 6>;
77
78/// Piano string-resonance level in dB, `-6..=6`, stored biased by 6.
79pub type ResonanceLevel = RangedI8<6, -6, 6>;
80
81/// Master tuning offset in cents, `-50..=50`, stored biased by 50.
82pub type FineTune = RangedI8<50, -50, 50>;
83
84/// The 34-byte settings body: the System, MIDI and Sound menus, interleaved in one
85/// bit space with the `startup_*` settings the instrument restores at power-up.
86/// Flat, because the two share bytes.
87///
88/// Reads and writes byte-exactly. A read verifies the container checksum, gates
89/// on [`KNOWN_VERSIONS`] and the aux word, and refuses any slot but `0:0`. Every
90/// placement is pinned by a change-one-setting sweep. Confirmed on hardware.
91#[bitbody(34)]
92pub struct Settings {
93    // ── System ─────────────────────────────────────────────────────────────────
94    #[bits(52..=53)]
95    pub rotary_ctrl_type: RotaryCtrlType,
96    #[bits(54..=54)]
97    pub rotary_pedal_mode: RotaryPedalMode,
98    #[bits(134..=135)]
99    pub sustain_pedal_mode: SustainPedalMode,
100    #[bits(43..=44)]
101    pub sustain_pedal_type: SustainPedalType,
102    #[bits(45..=47)]
103    pub ctrl_pedal_type: CtrlPedalType,
104    #[bits(138..=141)]
105    pub ctrl_pedal_gain: CtrlPedalGain,
106    #[bits(117..=117)]
107    pub b3_trig_mode: B3TrigMode,
108    #[bits(128..=128)]
109    pub output_routing: OutputRouting,
110    #[bits(68..=71)]
111    pub global_transpose: GlobalTranspose,
112    /// At `-50`, `0` and `+5`, written over USB, each moves the instrument's pitch by
113    /// its own value in cents. Confirmed on hardware. The values between: Inferred from
114    /// specimens; not confirmed on hardware.
115    #[bits(55..=61)]
116    pub fine_tune: FineTune,
117
118    // ── MIDI ───────────────────────────────────────────────────────────────────
119    #[bits(72..=76)]
120    pub global_channel: MidiChannel,
121    #[bits(118..=122)]
122    pub lower_receive_channel: MidiChannel,
123    #[bits(123..=127)]
124    pub upper_receive_channel: MidiChannel,
125    #[bits(129..=133)]
126    pub upper_split_channel: MidiChannel,
127    #[bits(38..=39)]
128    pub control_change_mode: MidiMessageMode,
129    #[bits(40..=41)]
130    pub program_change_mode: MidiMessageMode,
131    #[bits(137..=137)]
132    pub transpose_at: TransposeAt,
133
134    // ── Sound ──────────────────────────────────────────────────────────────────
135    /// Only `-6`, `0` and `+6` dB appear in the sweep. Every odd value: Inferred from
136    /// specimens; not confirmed on hardware. The bias matches [`GlobalTranspose`],
137    /// whose odd values the sweep does reach.
138    #[bits(64..=67)]
139    pub piano_string_resonance: ResonanceLevel,
140    #[bits(109..=111)]
141    pub b3_tonewheel_mode: TonewheelMode,
142    #[bits(113..=114)]
143    pub b3_key_click_level: KeyClickLevel,
144    #[bits(116..=116)]
145    pub b3_key_bounce: bool,
146    #[bits(112..=112)]
147    pub b3_perc_db9_mute: bool,
148    #[bits(97..=99)]
149    pub b3_perc_decay_fast: PercDecay,
150    #[bits(100..=102)]
151    pub b3_perc_decay_slow: PercDecay,
152    #[bits(103..=105)]
153    pub b3_perc_volume_normal: PercVolume,
154    #[bits(106..=108)]
155    pub b3_perc_volume_soft: PercVolume,
156    /// The panel dials through more entries than the two the corpus names, so an
157    /// unrecognized value here is expected rather than a decode failure.
158    #[bits(79..=81)]
159    pub rotary_speaker_type: RotarySpeakerType,
160    #[bits(94..=96)]
161    pub rotary_balance: RotaryBalance,
162    #[bits(82..=84)]
163    pub rotary_horn_speed: RotaryRate,
164    #[bits(88..=90)]
165    pub rotary_horn_acceleration: RotaryRate,
166    #[bits(85..=87)]
167    pub rotary_rotor_speed: RotaryRate,
168    #[bits(91..=93)]
169    pub rotary_rotor_acceleration: RotaryRate,
170
171    // Restored at boot; each field retains the last selection of its own mode.
172    // Locations use the song map's zero-based `bank * 50 + slot` packing.
173    /// Inferred from specimens; not confirmed on hardware. A set-list-mode capture sets
174    /// it, while a backup changes `startup_song` independently.
175    #[bits(16..=16)]
176    pub startup_set_list_mode: bool,
177    #[bits(17..=17)]
178    pub startup_live_mode: bool,
179    #[bits(19..=20)]
180    pub startup_live_slot: LiveSlot,
181    #[bits(21..=29)]
182    pub startup_program: program::Location,
183    /// Inferred from specimens; not confirmed on hardware.
184    #[bits(30..=37)]
185    pub startup_song: song::Location,
186}
187
188sparse_enum!(
189    /// Which of the three Live slots is selected.
190    LiveSlot, 2, {
191        0 => Live1, "live 1";
192        1 => Live2, "live 2";
193        2 => Live3, "live 3";
194    }
195);
196
197/// The instrument's menu a setting is shown under.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Menu {
200    System,
201    Midi,
202    Sound,
203}
204
205impl Menu {
206    pub fn title(&self) -> &'static str {
207        match self {
208            Menu::System => "System",
209            Menu::Midi => "MIDI",
210            Menu::Sound => "Sound",
211        }
212    }
213}
214
215/// One setting as the instrument's menu presents it.
216pub struct Setting {
217    /// The field's name in [`Settings`].
218    pub name: &'static str,
219    /// The value spelled the way the instrument spells it — `yamaha fc-7`, not the
220    /// variant name the bits decode to.
221    pub value: String,
222}
223
224/// Every field decodes from zeroed bytes — program `1:1`, Live and set list mode
225/// off — so this is the decode rather than a second statement of each default.
226impl Default for Settings {
227    fn default() -> Self {
228        Settings::try_from([0; BODY_LEN]).expect("every settings field decodes totally")
229    }
230}
231
232/// An on/off setting, as its menu entry names the two states.
233fn on_off(on: bool) -> String {
234    if on { "on" } else { "off" }.to_string()
235}
236
237impl Settings {
238    /// The panel's fields grouped by the menu the instrument shows them under, in menu
239    /// order — which is neither declaration order nor the order they sit in the file.
240    ///
241    /// ⚠️ These renderings are for reading, not for feeding back: `Display` is the panel's
242    /// wording, while [`Settings::set_field`] parses a field's `Debug`. A test holds the
243    /// list to the panel's own field names, so a field added to [`Settings`] and not
244    /// placed in a menu fails there.
245    pub fn by_menu(&self) -> Vec<(Menu, Vec<Setting>)> {
246        let at = |name, value: String| Setting { name, value };
247        vec![
248            (
249                Menu::System,
250                vec![
251                    at("rotary_ctrl_type", self.rotary_ctrl_type.to_string()),
252                    at("rotary_pedal_mode", self.rotary_pedal_mode.to_string()),
253                    at("sustain_pedal_mode", self.sustain_pedal_mode.to_string()),
254                    at("sustain_pedal_type", self.sustain_pedal_type.to_string()),
255                    at("ctrl_pedal_type", self.ctrl_pedal_type.to_string()),
256                    at("ctrl_pedal_gain", self.ctrl_pedal_gain.to_string()),
257                    at("b3_trig_mode", self.b3_trig_mode.to_string()),
258                    at("output_routing", self.output_routing.to_string()),
259                    at(
260                        "global_transpose",
261                        format!("{:+}", self.global_transpose.inner()),
262                    ),
263                    at("fine_tune", format!("{:+} cent", self.fine_tune.inner())),
264                ],
265            ),
266            (
267                Menu::Midi,
268                vec![
269                    at("global_channel", self.global_channel.to_string()),
270                    at(
271                        "lower_receive_channel",
272                        self.lower_receive_channel.to_string(),
273                    ),
274                    at(
275                        "upper_receive_channel",
276                        self.upper_receive_channel.to_string(),
277                    ),
278                    at("upper_split_channel", self.upper_split_channel.to_string()),
279                    at("control_change_mode", self.control_change_mode.to_string()),
280                    at("program_change_mode", self.program_change_mode.to_string()),
281                    at("transpose_at", self.transpose_at.to_string()),
282                ],
283            ),
284            (
285                Menu::Sound,
286                vec![
287                    at(
288                        "piano_string_resonance",
289                        format!("{:+} dB", self.piano_string_resonance.inner()),
290                    ),
291                    at("b3_tonewheel_mode", self.b3_tonewheel_mode.to_string()),
292                    at("b3_key_click_level", self.b3_key_click_level.to_string()),
293                    at("b3_key_bounce", on_off(self.b3_key_bounce)),
294                    at("b3_perc_db9_mute", on_off(self.b3_perc_db9_mute)),
295                    at("b3_perc_decay_fast", self.b3_perc_decay_fast.to_string()),
296                    at("b3_perc_decay_slow", self.b3_perc_decay_slow.to_string()),
297                    at(
298                        "b3_perc_volume_normal",
299                        self.b3_perc_volume_normal.to_string(),
300                    ),
301                    at("b3_perc_volume_soft", self.b3_perc_volume_soft.to_string()),
302                    at("rotary_speaker_type", self.rotary_speaker_type.to_string()),
303                    at("rotary_balance", self.rotary_balance.to_string()),
304                    at("rotary_horn_speed", self.rotary_horn_speed.to_string()),
305                    at(
306                        "rotary_horn_acceleration",
307                        self.rotary_horn_acceleration.to_string(),
308                    ),
309                    at("rotary_rotor_speed", self.rotary_rotor_speed.to_string()),
310                    at(
311                        "rotary_rotor_acceleration",
312                        self.rotary_rotor_acceleration.to_string(),
313                    ),
314                ],
315            ),
316        ]
317    }
318}
319
320sparse_enum!(
321    /// How the rotary speaker's speed is controlled.
322    RotaryCtrlType, 2, {
323        0 => Closed, "closed";
324        1 => Open, "open";
325        2 => HalfMoon, "half moon";
326    }
327);
328
329sparse_enum!(
330    /// Whether the rotary pedal runs fast while held or latches.
331    RotaryPedalMode, 1, {
332        0 => Hold, "hold";
333        1 => Toggle, "toggle";
334    }
335);
336
337sparse_enum!(
338    /// What the sustain pedal drives besides sustain.
339    SustainPedalMode, 2, {
340        0 => Sustain, "sustain";
341        1 => SustainRotorHold, "sustain + rotor hold";
342        2 => SustainRotorToggle, "sustain + rotor toggle";
343    }
344);
345
346sparse_enum!(
347    /// Sustain pedal polarity. `Auto` detects it at power-up.
348    SustainPedalType, 2, {
349        0 => Auto, "auto";
350        1 => Closed, "closed";
351        2 => Open, "open";
352    }
353);
354
355sparse_enum!(
356    /// Which expression pedal is plugged into the control input.
357    CtrlPedalType, 3, {
358        0 => RolandEv7, "roland ev-7";
359        1 => YamahaFc7, "yamaha fc-7";
360        2 => KorgExp2, "korg exp-2";
361        3 => KorgXvp10, "korg xvp-10";
362        4 => BossFv500L, "boss fv-500l";
363        5 => FatarSl, "fatar sl";
364    }
365);
366
367sparse_enum!(
368    /// How early the B3 key contacts trigger.
369    B3TrigMode, 1, {
370        0 => Normal, "normal";
371        1 => Fast, "fast";
372    }
373);
374
375sparse_enum!(
376    /// How the two parts are laid across the outputs.
377    OutputRouting, 1, {
378        0 => Stereo, "stereo";
379        1 => LowerLeftUpperRight, "lower L / upper R";
380    }
381);
382
383sparse_enum!(
384    /// Which directions a class of MIDI message travels.
385    MidiMessageMode, 2, {
386        0 => Off, "off";
387        1 => Send, "send";
388        2 => Receive, "receive";
389        3 => SendReceive, "send/receive";
390    }
391);
392
393sparse_enum!(
394    /// Which side of the MIDI port transposition is applied to.
395    TransposeAt, 1, {
396        0 => MidiIn, "midi in";
397        1 => MidiOut, "midi out";
398    }
399);
400
401sparse_enum!(
402    /// How much tonewheel leakage and crosstalk the B3 model adds.
403    TonewheelMode, 3, {
404        0 => Clean, "clean";
405        1 => Vintage1, "vintage 1";
406        2 => Vintage2, "vintage 2";
407        3 => Vintage3, "vintage 3";
408    }
409);
410
411sparse_enum!(
412    /// B3 key-click level.
413    KeyClickLevel, 2, {
414        0 => Low, "low";
415        1 => Normal, "normal";
416        2 => High, "high";
417        3 => Higher, "higher";
418    }
419);
420
421sparse_enum!(
422    /// B3 percussion decay length, per speed setting.
423    PercDecay, 3, {
424        0 => Short, "short";
425        1 => Medium, "medium";
426        2 => Long, "long";
427    }
428);
429
430sparse_enum!(
431    /// B3 percussion level, per volume setting.
432    PercVolume, 3, {
433        0 => Low, "low";
434        1 => Medium, "medium";
435        2 => High, "high";
436    }
437);
438
439sparse_enum!(
440    /// Which rotary cabinet the effect models.
441    RotarySpeakerType, 3, {
442        0 => Rotary122, "122";
443        1 => Rotary122Close, "122 close";
444    }
445);
446
447sparse_enum!(
448    /// Rotary bass/horn mix, as the panel spells it.
449    RotaryBalance, 3, {
450        0 => Bass70Horn30, "70/30";
451        1 => Bass60Horn40, "60/40";
452        2 => Bass50Horn50, "50/50";
453        3 => Bass40Horn60, "40/60";
454        4 => Bass30Horn70, "30/70";
455    }
456);
457
458sparse_enum!(
459    /// A rotary speed or acceleration trim. Shared by all four horn/rotor fields.
460    RotaryRate, 3, {
461        0 => Low, "low";
462        1 => Normal, "normal";
463        2 => High, "high";
464    }
465);
466
467/// A channel number as the panel numbers them, `1..=16`.
468///
469/// The invariant is the type's, not the caller's: the slot stores the number one lower,
470/// so a 0 or a 17 here would be written as some other channel.
471/// [`MidiChannel::channel`] is the only way to build one.
472#[derive(Copy, Clone, PartialEq, Eq)]
473pub struct ChannelNumber(u8);
474
475impl ChannelNumber {
476    /// The panel's channel number, `1..=16`.
477    pub fn get(self) -> u8 {
478        self.0
479    }
480}
481
482/// A MIDI channel slot: `1..=16`, or off.
483///
484/// Stored zero-based, with 16 for off. A pattern above that has no meaning and is kept
485/// as [`MidiChannel::Unknown`] rather than folded into a channel.
486#[derive(Copy, Clone, PartialEq, Eq)]
487pub enum MidiChannel {
488    /// Channel `1..=16`, as the panel numbers them — see [`MidiChannel::channel`].
489    Channel(ChannelNumber),
490    Off,
491    /// A stored pattern with no known meaning, bounded by the five bits it came from.
492    Unknown(RangedU8<31>),
493}
494
495impl MidiChannel {
496    /// How many channels the panel numbers.
497    pub const CHANNELS: u8 = 16;
498
499    /// Channel `1..=16`.
500    pub fn channel(number: u8) -> Result<Self, ParseError> {
501        if !(1..=Self::CHANNELS).contains(&number) {
502            return Err(ParseError::OutOfBounds {
503                value: format!("{number}"),
504                bound: format!("1..={}", Self::CHANNELS),
505            });
506        }
507        Ok(MidiChannel::Channel(ChannelNumber(number)))
508    }
509
510    /// The panel's channel number, or `None` for off or unknown.
511    pub fn number(&self) -> Option<u8> {
512        match self {
513            MidiChannel::Channel(n) => Some(n.get()),
514            _ => None,
515        }
516    }
517}
518
519impl crate::bits::Packed for MidiChannel {
520    const MAX_BITS: u32 = 5;
521    const DECODE_BITS: u32 = u8::BITS;
522    const CONTROL: crate::fields::ControlKind = crate::fields::ControlKind::Selector;
523    type Error = ParseError;
524
525    fn from_bits(bits: u64) -> Result<Self, ParseError> {
526        Ok(match bits as u8 {
527            n if n < Self::CHANNELS => MidiChannel::Channel(ChannelNumber(n + 1)),
528            16 => MidiChannel::Off,
529            other => MidiChannel::Unknown(other.try_into()?),
530        })
531    }
532
533    fn to_bits(&self) -> u64 {
534        match self {
535            // `1..=16` by the payload's own invariant, so the stored value is in range.
536            MidiChannel::Channel(n) => u64::from(n.get() - 1),
537            MidiChannel::Off => 16,
538            MidiChannel::Unknown(raw) => u64::from(raw.as_u8()),
539        }
540    }
541}
542
543impl Debug for MidiChannel {
544    /// The channel number alone, so `1` is spelled `1` and not `Channel(1)`.
545    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
546        match self {
547            MidiChannel::Channel(n) => write!(f, "{}", n.get()),
548            MidiChannel::Off => f.write_str("off"),
549            MidiChannel::Unknown(raw) => write!(f, "unknown ({})", raw.as_u8()),
550        }
551    }
552}
553
554impl Display for MidiChannel {
555    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
556        write!(f, "{self:?}")
557    }
558}
559
560/// Control pedal gain, `1..=10` as the panel reads it. Stored one less.
561#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
562pub struct CtrlPedalGain(u8);
563
564impl CtrlPedalGain {
565    pub const MIN: u8 = 1;
566    pub const MAX: u8 = 10;
567
568    pub fn new(gain: u8) -> Result<Self, ParseError> {
569        if !(Self::MIN..=Self::MAX).contains(&gain) {
570            return Err(ParseError::OutOfBounds {
571                value: format!("{gain}"),
572                bound: format!("{}..={}", Self::MIN, Self::MAX),
573            });
574        }
575        Ok(CtrlPedalGain(gain))
576    }
577
578    /// The panel's reading, `1..=10`.
579    pub fn as_u8(&self) -> u8 {
580        self.0
581    }
582}
583
584impl Default for CtrlPedalGain {
585    fn default() -> Self {
586        CtrlPedalGain(Self::MIN)
587    }
588}
589
590impl crate::bits::Packed for CtrlPedalGain {
591    const MAX_BITS: u32 = 4;
592    const DECODE_BITS: u32 = u8::BITS;
593    const CONTROL: crate::fields::ControlKind =
594        crate::fields::ControlKind::Knob(crate::fields::Unit::None);
595    type Error = ParseError;
596
597    fn from_bits(bits: u64) -> Result<Self, ParseError> {
598        CtrlPedalGain::new((bits as u8).saturating_add(1))
599    }
600
601    fn to_bits(&self) -> u64 {
602        u64::from(self.0 - Self::MIN)
603    }
604}
605
606impl Debug for CtrlPedalGain {
607    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
608        write!(f, "{}", self.0)
609    }
610}
611
612impl Display for CtrlPedalGain {
613    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
614        write!(f, "{}", self.0)
615    }
616}
617
618impl PartialEq<u8> for CtrlPedalGain {
619    fn eq(&self, other: &u8) -> bool {
620        self.0 == *other
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::bits::Packed;
628    use std::collections::{BTreeMap, BTreeSet};
629    use std::io::Cursor;
630
631    /// Body-relative index of the byte at absolute Electro 5 file offset `abs`.
632    const fn body(abs: usize) -> usize {
633        abs - 0x2c
634    }
635
636    /// Build a settings panel from `(absolute offset, byte)` pairs; everything else 0.
637    fn panel(bytes: &[(usize, u8)]) -> Settings {
638        let mut raw = [0u8; BODY_LEN];
639        for &(at, b) in bytes {
640            raw[body(at)] = b;
641        }
642        Settings::try_from(raw).expect("every settings field decodes totally")
643    }
644
645    #[test]
646    fn settings_round_trip_at_the_declared_length() {
647        let settings = new();
648        let mut bytes = Vec::new();
649        settings.write_to(&mut Cursor::new(&mut bytes)).unwrap();
650        assert_eq!(bytes.len(), FILE_LEN);
651        assert_eq!(&bytes[0x08..0x0c], FORMAT.as_bytes());
652
653        let back = read_from(&mut Cursor::new(&mut bytes)).unwrap();
654        let mut again = Vec::new();
655        back.write_to(&mut Cursor::new(&mut again)).unwrap();
656        assert_eq!(bytes, again);
657    }
658
659    /// There is one settings file per instrument, so a file claiming a slot is not
660    /// one of them.
661    #[test]
662    fn a_settings_file_addressed_to_a_slot_is_refused() {
663        let mut settings = new();
664        settings.header.set_slot((1, 0));
665        let mut bytes = Vec::new();
666        settings.write_to(&mut Cursor::new(&mut bytes)).unwrap();
667
668        let err = read_from(&mut Cursor::new(&bytes))
669            .expect_err("a located settings file must not decode");
670        assert!(
671            matches!(err, Error::Parse(ParseError::AssertFail(_))),
672            "refused for the wrong reason: {err}",
673        );
674    }
675
676    #[test]
677    fn a_settings_field_set_by_name_survives_a_round_trip() {
678        let mut settings = new();
679        settings.set_field("global_transpose", "-3").unwrap();
680
681        let mut bytes = Vec::new();
682        settings.write_to(&mut Cursor::new(&mut bytes)).unwrap();
683        let back = read_from(&mut Cursor::new(&mut bytes)).unwrap();
684        let listed = back
685            .fields()
686            .into_iter()
687            .find(|f| f.path == "global_transpose")
688            .expect("declared");
689        assert_eq!(listed.display, "-3");
690    }
691
692    /// Every declared menu field belongs to exactly one menu, and every menu names
693    /// only declared fields. The `startup_*` settings are in no menu — the
694    /// instrument shows them nowhere — so they are excluded rather than missing.
695    #[test]
696    fn every_field_is_listed_under_one_menu() {
697        let declared: BTreeSet<String> = Settings::field_specs()
698            .into_iter()
699            .map(|f| f.name)
700            .filter(|name| !name.starts_with("startup_"))
701            .collect();
702
703        let mut grouped: BTreeSet<String> = BTreeSet::new();
704        for (menu, settings) in Settings::default().by_menu() {
705            for setting in settings {
706                assert!(
707                    declared.contains(setting.name),
708                    "{} lists {}, which the panel does not declare",
709                    menu.title(),
710                    setting.name,
711                );
712                assert!(
713                    grouped.insert(setting.name.to_string()),
714                    "{} is listed under two menus",
715                    setting.name,
716                );
717            }
718        }
719        let missing: Vec<_> = declared.difference(&grouped).collect();
720        assert!(missing.is_empty(), "fields with no menu: {missing:?}");
721    }
722
723    /// A menu renders what the instrument's display says, not what the bits decode to.
724    ///
725    /// ⚠️ These spellings are read-only. `set_field` parses a field's `Debug`, so
726    /// `yamaha fc-7` is not a value anything accepts back.
727    #[test]
728    fn a_menu_renders_the_panels_own_wording() {
729        // The sweep's reference capture, rebuilt from the bytes it holds at 0x2c..=0x3d.
730        let p = panel(&[
731            (0x2e, 0x13),
732            (0x2f, 0x24),
733            (0x30, 0x03),
734            (0x31, 0xc1),
735            (0x32, 0x06),
736            (0x33, 0xdc),
737            (0x34, 0x66),
738            (0x35, 0x00),
739            (0x36, 0x09),
740            (0x37, 0x25),
741            (0x38, 0x12),
742            (0x39, 0x49),
743            (0x3a, 0x2d),
744            (0x3b, 0x61),
745            (0x3c, 0x06),
746            (0x3d, 0x24),
747        ]);
748        let rendered: BTreeMap<&str, String> = p
749            .by_menu()
750            .into_iter()
751            .flat_map(|(_, settings)| settings)
752            .map(|s| (s.name, s.value))
753            .collect();
754
755        for (field, want) in [
756            ("ctrl_pedal_type", "yamaha fc-7"),
757            ("ctrl_pedal_gain", "10"),
758            ("sustain_pedal_mode", "sustain + rotor toggle"),
759            ("output_routing", "stereo"),
760            ("global_transpose", "+0"),
761            ("fine_tune", "+5 cent"),
762            ("global_channel", "1"),
763            ("control_change_mode", "send/receive"),
764            ("transpose_at", "midi in"),
765            ("piano_string_resonance", "+0 dB"),
766            ("b3_tonewheel_mode", "vintage 1"),
767            ("b3_key_bounce", "on"),
768            ("b3_perc_db9_mute", "off"),
769            ("rotary_speaker_type", "122"),
770            ("rotary_balance", "50/50"),
771            ("rotary_rotor_acceleration", "normal"),
772        ] {
773            assert_eq!(rendered[field], want, "{field}");
774        }
775    }
776
777    /// An unnamed value says so rather than being rendered as a neighbor.
778    #[test]
779    fn a_menu_names_an_unrecognized_value_as_unknown() {
780        // 0b111 is not a rotary speaker type; bits 79..=81 straddle 0x35 and 0x36.
781        let p = panel(&[(0x35, 0x01), (0x36, 0xc0)]);
782        let shown = p
783            .by_menu()
784            .into_iter()
785            .flat_map(|(_, settings)| settings)
786            .find(|s| s.name == "rotary_speaker_type")
787            .expect("declared")
788            .value;
789        assert_eq!(shown, "unknown (7)");
790    }
791
792    /// The two signed fields are stored biased, so their endpoints are the cases worth
793    /// pinning: the bias is what an off-by-one shows up in.
794    #[test]
795    fn global_transpose_stores_minus_six_as_zero() {
796        // Bits 68..=71 are the low nibble of 0x34.
797        for (semitones, stored) in [(-6i8, 0x0u8), (-1, 0x5), (0, 0x6), (1, 0x7), (6, 0xc)] {
798            let p = panel(&[(0x34, stored)]);
799            assert_eq!(p.global_transpose, semitones, "stored {stored:#x}");
800            assert_eq!(
801                <[u8; BODY_LEN]>::from(&p)[body(0x34)],
802                stored,
803                "{semitones} did not write back"
804            );
805        }
806        // The high nibble is the string resonance and must not leak in.
807        assert_eq!(panel(&[(0x34, 0xc6)]).global_transpose, 0);
808        assert_eq!(panel(&[(0x34, 0xc6)]).piano_string_resonance, 6);
809    }
810
811    #[test]
812    fn fine_tune_stores_minus_fifty_cents_as_zero() {
813        // Bits 55..=61: the low bit of 0x32 and the top six of 0x33.
814        for (cents, at32, at33) in [
815            (-50i8, 0x00u8, 0x00u8),
816            (0, 0x00, 0xc8),
817            (5, 0x00, 0xdc),
818            (50, 0x01, 0x90),
819        ] {
820            let p = panel(&[(0x32, at32), (0x33, at33)]);
821            assert_eq!(p.fine_tune, cents, "stored {at32:#04x} {at33:#04x}");
822            let back = <[u8; BODY_LEN]>::from(&p);
823            assert_eq!(
824                (back[body(0x32)], back[body(0x33)]),
825                (at32, at33),
826                "{cents} did not write back"
827            );
828        }
829        // A value past +50 is not a fine tune, so the panel refuses to decode at all.
830        let mut raw = [0u8; BODY_LEN];
831        raw[body(0x32)] = 0x01;
832        raw[body(0x33)] = 0xfc;
833        assert!(
834            Settings::try_from(raw).is_err(),
835            "a stored 127 decoded, and biased by 50 that is +77 cents"
836        );
837    }
838
839    /// Channels are stored zero-based with 16 for off, so the two ends and the off value
840    /// establish the encoding.
841    #[test]
842    fn a_midi_channel_is_stored_zero_based_with_sixteen_for_off() {
843        let unknown = |raw: u8| MidiChannel::Unknown(raw.try_into().unwrap());
844        for (bits, channel) in [
845            (0u64, MidiChannel::channel(1).unwrap()),
846            (1, MidiChannel::channel(2).unwrap()),
847            (15, MidiChannel::channel(16).unwrap()),
848            (16, MidiChannel::Off),
849            (17, unknown(17)),
850            (31, unknown(31)),
851        ] {
852            assert_eq!(MidiChannel::from_bits(bits).unwrap(), channel);
853            assert_eq!(channel.to_bits(), bits, "{channel:?} does not round-trip");
854        }
855        assert_eq!(format!("{:?}", MidiChannel::channel(7).unwrap()), "7");
856        assert_eq!(format!("{:?}", MidiChannel::Off), "off");
857    }
858
859    /// The channel a caller can build is the channel the panel numbers: every other
860    /// number is refused rather than stored as a neighbour.
861    #[test]
862    fn only_the_panels_sixteen_channels_can_be_built() {
863        for number in 1..=MidiChannel::CHANNELS {
864            let channel = MidiChannel::channel(number).unwrap();
865            assert_eq!(channel.number(), Some(number));
866            assert_eq!(channel.to_bits(), u64::from(number) - 1);
867        }
868        assert!(MidiChannel::channel(0).is_err());
869        assert!(MidiChannel::channel(17).is_err());
870        // The unknown payload is the slot's own five bits.
871        assert!(RangedU8::<31>::new(32).is_err());
872    }
873
874    /// Gain is one-based on the panel and zero-based in the file.
875    #[test]
876    fn ctrl_pedal_gain_is_stored_one_less_than_the_panel_reads() {
877        for gain in CtrlPedalGain::MIN..=CtrlPedalGain::MAX {
878            let stored = CtrlPedalGain::new(gain).unwrap().to_bits();
879            assert_eq!(stored, u64::from(gain) - 1);
880            assert_eq!(CtrlPedalGain::from_bits(stored).unwrap(), gain);
881        }
882        assert!(CtrlPedalGain::new(0).is_err());
883        assert!(CtrlPedalGain::new(11).is_err());
884        // Ten is the widest the four-bit slot may hold, so 10..=15 do not decode.
885        assert!(CtrlPedalGain::from_bits(10).is_err());
886    }
887
888    /// A default panel is the decode of zeroed bytes, and re-encoding it gives them back.
889    #[test]
890    fn the_default_panel_encodes_and_decodes() {
891        let p = Settings::default();
892        assert_eq!(<[u8; BODY_LEN]>::from(&p), [0; BODY_LEN]);
893        assert_eq!(p.global_transpose, -6);
894        assert_eq!(p.fine_tune, -50);
895        assert_eq!(p.ctrl_pedal_gain, 1);
896        assert_eq!(p.global_channel, MidiChannel::channel(1).unwrap());
897    }
898
899    /// Setting a field lands in its own bits and disturbs no other byte.
900    #[test]
901    fn setting_a_field_moves_only_its_own_bytes() {
902        let mut p = panel(&[]);
903        p.b3_tonewheel_mode = TonewheelMode::Vintage3;
904        let raw = <[u8; BODY_LEN]>::from(&p);
905
906        let moved: Vec<usize> = (0..BODY_LEN).filter(|&i| raw[i] != 0).collect();
907        // Bits 109..=111 are the low three of 0x39.
908        assert_eq!(moved, vec![body(0x39)], "{moved:x?}");
909        assert_eq!(raw[body(0x39)], 0x03);
910    }
911
912    /// Decoding never invents a value: an unnamed pattern comes back as it went in.
913    #[test]
914    fn an_unrecognized_pattern_round_trips() {
915        // 0b111 is not a rotary speaker type; bits 79..=81 straddle 0x35 and 0x36.
916        let p = panel(&[(0x35, 0x01), (0x36, 0xc0)]);
917        assert!(p.rotary_speaker_type.is_unknown());
918        let raw = <[u8; BODY_LEN]>::from(&p);
919        assert_eq!((raw[body(0x35)], raw[body(0x36)]), (0x01, 0xc0));
920    }
921}