nord-format 0.6.0

Read and write Nord keyboard files from Rust, byte for byte
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
//! Sample instruments (`.nsmp`) — the Nord Sample Library format.
//!
//! Shared across the Nord line rather than specific to one model, so it carries its own
//! tag rather than a model's. A file is the CBIN header followed by a chain of tagged
//! [`section`]s: an `hdr` carrying the name, a `cat` of category strings, a `map`
//! ending in the [`zone`] table, one [`stroke`] per zone, and a trailing `sty`.
//!
//! **Strokes are stored verbatim**, so this reads and rewrites instruments byte-exactly
//! and can retune, rename and remap them without touching a byte of audio, in either
//! chain. The [`codec`] decodes that audio to samples in every generation — it is one
//! codec in three sets of units, so a caller only picks the right [`codec::Layout`].
//! [`encode`] builds a new instrument from PCM in all three generations.

/// A zone and the stroke stream that plays it, ready for [`codec::decode`].
pub struct ZoneAudio<'a> {
    pub root_key: u8,
    pub top_note: u8,
    /// Lowest note, where the generation stores one. `None` where zones tile and
    /// a zone's bottom is one above the next-lower zone's top.
    pub low_note: Option<u8>,
    /// The stream's offset from the start of the body, which is the base its own
    /// word directory was written against.
    pub at: usize,
    pub stream: &'a [u8],
}

pub mod codec;
pub mod encode;
pub mod kernel;
pub mod keymap;
pub mod meta;
pub mod section;
pub mod stroke;
pub mod sty;
pub mod zone;

pub use keymap::{KeyTable, Level};
pub use meta::Meta;
pub use section::Section;
pub use stroke::Stroke;
pub use sty::{velocity_level, EqBand, Sty, StyV2, StyV3};
pub use zone::Zone;
pub use zone::ZoneV3;

use crate::cbin::{self, BodyReader, BodyWriter, Cbin, Header};
use crate::error::{Error, ParseError};
use std::fmt;
use std::io::{Read, Seek, Write};

pub const FORMAT: &str = "nsmp";

/// The content version at which the body leaves the `NWS` chain for the wide
/// `NSMP` chain. All generations share the `nsmp` tag; the u32 at `0x14` is the
/// generation marker, running `format × 100 + revision` — `.nsmp3` content
/// stores 300 and up, `.nsmp4` 400 and up.
pub const V3_FROM_VERSION: u32 = 300;

/// The content version at which the wide chain becomes v4. Same chain and the same
/// stream units as v3 — what changes is the codec, so the number matters to
/// [`codec::Layout`] rather than to the reader.
pub const V4_FROM_VERSION: u32 = 400;

/// Which section chain a body's sections form, and the shapes that follow from it.
///
/// The narrow chain has two schemas and the content version does not separate them —
/// it tracks the library release, and releases on both sides of the change carry a
/// spread of numbers. The gate is the `map` section's own version, which the other
/// section versions agree with on every specimen.
///
/// Inferred from specimens; not confirmed on hardware.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chain {
    /// `NWS` 8 / `hdr` 8 / `map` 9 / `stk` 8 / `sty` 5, and no `cat` section at all.
    /// The `hdr` is 18 bytes with no name field: these instruments carry no name,
    /// and the library's filename is the only one they have.
    Early,
    /// `NWS` 11 / `hdr` 9 / `cat` 5 / `map` 10 / `stk` 9 / `sty` 5, the `hdr` naming
    /// the instrument.
    Library2,
    /// The `NSMP` chain of the wide generations.
    Wide,
}

impl Chain {
    /// The narrow chain a `map` section version selects.
    pub fn from_map_version(version: u8) -> Result<Chain, ParseError> {
        match version {
            keymap::VERSION_EARLY => Ok(Chain::Early),
            keymap::VERSION => Ok(Chain::Library2),
            other => Err(ParseError::AssertFail(format!(
                "map section version {other} has no zone table layout derived from a specimen"
            ))),
        }
    }

    /// Bytes per zone record. [`Chain::Library2`] appends a flag and two zero bytes
    /// to the twelve [`Chain::Early`] carries; every field they share is at the same
    /// offset.
    pub const fn zone_record_len(self) -> usize {
        match self {
            Chain::Early => 12,
            Chain::Library2 | Chain::Wide => 15,
        }
    }

    /// The chain [`encode`] emits for a stream layout. It writes the current schemas
    /// only: [`Chain::Early`] is read, never produced.
    pub const fn written_for(layout: codec::Layout) -> Chain {
        match layout {
            codec::Layout::V2 => Chain::Library2,
            codec::Layout::V3 | codec::Layout::V4 => Chain::Wide,
        }
    }

    /// Whether the `hdr` carries an instrument name.
    pub const fn names_instrument(self) -> bool {
        !matches!(self, Chain::Early)
    }

    /// Whether a looped stroke also sets the mark bit on the record its directory
    /// points at. [`Chain::Early`] never does — the pointer alone marks the loop —
    /// so a reader that requires the flag rejects those libraries outright.
    pub const fn flags_the_marked_record(self) -> bool {
        !matches!(self, Chain::Early)
    }
}

/// A body decoded by generation: v2 in full, v3/v4 as a section chain with
/// strokes verbatim.
///
/// ⚠️ The v2 pool also holds versions that are not `2xx` — 8 (the original
/// Sample Library) and 200 (Sample Library 2.0) — so the gate is "at least
/// 300", not "exactly 2xx". Inferred from specimens; not confirmed on hardware.
/// The number tracks the library release rather than the codec. Reported by
/// public documentation; not confirmed on hardware.
#[derive(Debug)]
pub enum AnyBody {
    V2(Sample),
    V3(SampleV3),
}

impl cbin::Body for AnyBody {
    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, header: &Header) -> Result<Self, Error> {
        if header.version >= V3_FROM_VERSION {
            Ok(AnyBody::V3(<SampleV3 as cbin::Body>::read(r, header)?))
        } else {
            Ok(AnyBody::V2(<Sample as cbin::Body>::read(r, header)?))
        }
    }

    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
        match self {
            AnyBody::V2(s) => <Sample as cbin::Body>::write(s, w),
            AnyBody::V3(s) => <SampleV3 as cbin::Body>::write(s, w),
        }
    }
}

/// A fixed-width string field inside a `hdr` payload: where it starts, and where the
/// next field does.
///
/// A `hdr` holds its strings NUL-terminated and zero-padded to the next field, never
/// length-prefixed, so the field is the same size whatever it holds and the longest
/// string it takes is its span less the terminator. The editor's own name box stops
/// well short of that; shipped libraries do not.
///
/// Inferred from specimens; not confirmed on hardware.
#[derive(Clone, Copy)]
pub(super) struct StringField {
    at: usize,
    next: usize,
}

impl StringField {
    /// The narrow chain's instrument name. The sub-name follows it.
    pub(super) const NAME: StringField = StringField { at: 12, next: 44 };

    /// The wide chain's main name, in both wide generations.
    pub(super) const NAME_V3: StringField = StringField { at: 10, next: 76 };

    /// Longest string this field holds, the terminator excluded.
    pub(super) const fn capacity(self) -> usize {
        self.next - self.at - 1
    }

    /// The string, up to its terminator.
    ///
    /// A payload that stops inside the field is read as far as it goes rather than
    /// refused: the oldest narrow `hdr` is 18 bytes and carries no name at all, and it
    /// reads back empty.
    fn read(self, payload: &[u8]) -> String {
        let span = self.at.min(payload.len())..self.next.min(payload.len());
        nul_terminated(&payload[span])
    }

    /// Replaces the string, zero-filling the rest of the field.
    pub(super) fn write(self, payload: &mut [u8], value: &str) -> Result<(), Error> {
        if value.len() > self.capacity() {
            return Err(ParseError::OutOfBounds {
                value: format!("{value:?} ({} bytes)", value.len()),
                bound: format!("a name of at most {} bytes", self.capacity()),
            }
            .into());
        }
        let field = payload
            .get_mut(self.at..self.next)
            .ok_or_else(|| ParseError::AssertFail("hdr section holds no name field".into()))?;
        field.fill(0);
        field[..value.len()].copy_from_slice(value.as_bytes());
        Ok(())
    }
}

/// What a NUL-terminated, zero-padded field holds. An unterminated field is the whole
/// of it.
fn nul_terminated(bytes: &[u8]) -> String {
    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
    String::from_utf8_lossy(&bytes[..end]).into_owned()
}

/// Longest instrument name the narrow chain holds.
pub const MAX_NAME_LEN: usize = StringField::NAME.capacity();

/// A sample instrument's body: the section chain, held in file order including
/// repeats — `stk` appears once per zone. A file is a `Cbin<Sample>`.
///
/// Reads and writes byte-exactly, checksum verified. The name, categories, zones
/// and stroke metadata decode and are editable; the audio stays verbatim.
pub struct Sample {
    pub sections: Vec<Section>,
}

impl cbin::Body for Sample {
    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, _: &Header) -> Result<Self, Error> {
        let remaining = r.remaining();
        Ok(Sample {
            sections: section::read_chain(r, remaining)?,
        })
    }

    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
        for s in &self.sections {
            s.write_to(w)?;
        }
        Ok(())
    }
}

/// Reads a whole instrument, verifying its checksum.
pub fn read_from(reader: &mut (impl Read + Seek)) -> Result<Cbin<Sample>, Error> {
    cbin::read(reader, FORMAT)
}

/// A v3/v4 body: the wide-section (`NSMP`) chain, held in file order including
/// repeats — `stk` appears once per stroke. Sections are preserved verbatim, so
/// a file round-trips byte-exactly, and the name, zone boundaries and root keys
/// patch in place without touching the audio.
///
/// Every corpus specimen chains `NSMP`, `hdr`, `cat`, `map`, N × `stk`, `sty`,
/// `meta`, in that order, in both container generations. Inferred from
/// specimens; not confirmed on hardware.
///
/// The stroke payloads are the encoded audio. The enclosing content version selects
/// [`codec::Layout::V3`] or [`codec::Layout::V4`] through [`codec::Layout::from_version`].
#[derive(Debug)]
pub struct SampleV3 {
    pub sections: Vec<section::Section4>,
}

impl cbin::Body for SampleV3 {
    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, _: &Header) -> Result<Self, Error> {
        let remaining = r.remaining();
        Ok(SampleV3 {
            sections: section::read_chain4(r, remaining)?,
        })
    }

    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
        for s in &self.sections {
            s.write_to(w)?;
        }
        Ok(())
    }
}

/// Longest main name the wide chain holds. The two fields around it are what the
/// filename convention joins — `Bass Clarinet 2` + `KG  mono` → `Bass Clarinet
/// 2_KG  mono 3.11`.
pub const MAX_NAME_V3_LEN: usize = StringField::NAME_V3.capacity();

impl Cbin<SampleV3> {
    fn hdr(&self) -> Result<&section::Section4, Error> {
        section::find4(&self.body.sections, section::HDR4)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()).into())
    }

    /// The instrument's main name.
    pub fn name(&self) -> Result<String, Error> {
        Ok(StringField::NAME_V3.read(&self.hdr()?.payload))
    }

    /// The sub name — the string after the `_` in the vendor's filenames.
    /// Empty on files that carry none.
    ///
    /// It starts where the main name's field ends. Where it ends is unmapped, so this
    /// reads to the terminator with no field bound behind it and there is no setter.
    pub fn sub_name(&self) -> Result<String, Error> {
        let payload = &self.hdr()?.payload;
        let from = StringField::NAME_V3.next.min(payload.len());
        Ok(nul_terminated(&payload[from..]))
    }

    /// How many strokes the body carries — one `stk` section each.
    pub fn stroke_count(&self) -> usize {
        self.body
            .sections
            .iter()
            .filter(|s| s.is(section::STK4))
            .count()
    }

    /// Each stroke's `(global id, root key)` — the u32 its payload leads with,
    /// and the byte at offset 5. Inferred from specimens; not confirmed on
    /// hardware.
    fn stroke_ids(&self) -> Result<Vec<(u32, u8)>, Error> {
        self.body
            .sections
            .iter()
            .filter(|s| s.is(section::STK4))
            .map(|s| match (stroke_gid(s), s.payload.get(5)) {
                (Some(gid), Some(&root)) => Ok((gid, root)),
                _ => Err(ParseError::AssertFail(format!(
                    "stroke payload is {} bytes, too short for its id fields",
                    s.payload.len()
                ))
                .into()),
            })
            .collect()
    }

    /// Keyboard zones, in stored order, which is usually high to low; `map` v14
    /// files occur in both orders and a record states its own notes. Each zone is
    /// verified against the stroke it names.
    pub fn zones(&self) -> Result<Vec<ZoneV3>, Error> {
        let map = self.map()?;
        Ok(zone::read_v3(
            map.version,
            &map.payload,
            &self.stroke_ids()?,
        )?)
    }

    fn map(&self) -> Result<&section::Section4, Error> {
        section::find4(&self.body.sections, section::MAP4)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()).into())
    }

    /// The instrument's default sound preset, under the schema its section
    /// version selects.
    pub fn sty(&self) -> Result<Sty, Error> {
        let s = section::find4(&self.body.sections, section::STY4)
            .ok_or_else(|| ParseError::AssertFail("no sty section".into()))?;
        Ok(Sty::parse_wide(s.version, &s.payload)?)
    }

    /// The chain's own length, as its closing `meta` section states it.
    pub fn meta(&self) -> Result<Meta, Error> {
        let s = section::find4(&self.body.sections, section::META4)
            .ok_or_else(|| ParseError::AssertFail("no meta section".into()))?;
        Ok(Meta::parse(s.version, &s.payload)?)
    }

    /// The length `meta` should state: every section ahead of it on the wire.
    pub fn chain_len_before_meta(&self) -> usize {
        self.body
            .sections
            .iter()
            .take_while(|s| !s.is(section::META4))
            .map(section::Section4::encoded_len)
            .sum()
    }

    fn map_mut(&mut self) -> Result<&mut section::Section4, Error> {
        section::find_mut4(&mut self.body.sections, section::MAP4)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()).into())
    }

    /// The zone table, located the same way [`Self::zones`] locates it.
    ///
    /// Carries the record layout and, through [`zone::Table::key_map`], what the
    /// `map`'s per-key table holds.
    pub fn zone_table(&self) -> Result<zone::Table, Error> {
        let map = self.map()?;
        Ok(zone::Table::locate(
            map.version,
            &map.payload,
            &self.stroke_ids()?,
        )?)
    }

    /// Renames in place, NUL-padding the rest of the main-name field. The
    /// sub-name is a separate field and is left alone.
    pub fn set_name(&mut self, name: &str) -> Result<(), Error> {
        let hdr = section::find_mut4(&mut self.body.sections, section::HDR4)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()))?;
        StringField::NAME_V3.write(&mut hdr.payload, name)
    }

    /// Whether this body's zones can be retuned and remapped.
    ///
    /// True wherever the zone table reads and, if the `map` also describes the
    /// keyboard note by note, that table can be recomputed from the layout.
    pub fn zones_are_editable(&self) -> bool {
        match (self.zone_table(), self.map(), self.zones()) {
            (Ok(table), Ok(map), Ok(zones)) => table.validate_key_map(&map.payload, &zones).is_ok(),
            _ => false,
        }
    }

    /// Apply one zone-record edit, keeping the `map`'s per-key table in step.
    ///
    /// The layout the edit produces is worked out and the table planned from it
    /// before any byte moves, so a layout the partner law cannot read refuses
    /// rather than half-applying.
    fn edit_zone(&mut self, index: usize, field: zone::Field, note: u8) -> Result<(), Error> {
        let table = self.zone_table()?;
        let mut zones = self.zones()?;
        let map = self.map()?;
        table.validate_key_map(&map.payload, &zones)?;
        let zone = zones
            .get_mut(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?;
        match field {
            zone::Field::Root => zone.root_key = note,
            zone::Field::Top => zone.top_note = note,
            zone::Field::Low => zone.low_note = Some(note),
        }
        let plan = table.plan_key_map(&map.payload, &zones)?;
        let map = self.map_mut()?;
        table.set(&mut map.payload, index, field, note)?;
        for (at, quad) in plan {
            map.payload[at..at + quad.len()].copy_from_slice(&quad);
        }
        Ok(())
    }

    /// Sets one zone's top note, in [`Self::zones`] order. The strokes are untouched.
    pub fn set_zone_top_note(&mut self, index: usize, note: u8) -> Result<(), Error> {
        self.edit_zone(index, zone::Field::Top, note)
    }

    /// Sets one zone's lowest note, on the layouts that store one.
    pub fn set_zone_low_note(&mut self, index: usize, note: u8) -> Result<(), Error> {
        self.edit_zone(index, zone::Field::Low, note)
    }

    /// Retunes one zone by moving the note its sample plays untransposed at.
    ///
    /// ⚠️ The root key is stored twice — once in the stroke, once duplicated into
    /// the zone record — and the table stops reading if the two disagree, so both
    /// move here or neither does.
    pub fn set_root_key(&mut self, index: usize, note: u8) -> Result<(), Error> {
        let gid = self
            .zones()?
            .get(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?
            .stroke_gid;
        // Both copies are located before either moves: a half-written pair is a
        // file whose zone table no longer reads.
        let at = self
            .body
            .sections
            .iter()
            .position(|s| s.is(section::STK4) && stroke_gid(s) == Some(gid))
            .ok_or_else(|| {
                ParseError::AssertFail(format!(
                    "zone {index} names stroke {gid}, which the file does not contain"
                ))
            })?;
        self.edit_zone(index, zone::Field::Root, note)?;
        stroke::set_root_key(&mut self.body.sections[at].payload, note)?;
        Ok(())
    }

    /// Every stroke's encoded stream with its offset from the start of the body, in
    /// file order.
    ///
    /// The offset is the base the stroke's own [`codec::Directory`] is written
    /// against, so a caller checking those pointers needs this pairing rather than
    /// the payload alone. Decode the streams with
    /// [`codec::Layout::from_version(self.header.version)`](codec::Layout::from_version).
    pub fn stroke_streams(&self) -> Vec<(usize, &[u8])> {
        let mut at = 0;
        let mut out = Vec::new();
        for section in &self.body.sections {
            if section.is(section::STK4) {
                out.push((at + section::HEADER4_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        out
    }

    /// One zone's encoded stream, in [`Self::zones`] order. Decode it with
    /// [`codec::Layout::from_version(self.header.version)`](codec::Layout::from_version).
    ///
    /// Paired by the global id the zone record names, so it is safe on library
    /// content whose strokes are not in zone order.
    pub fn zone_stream(&self, index: usize) -> Result<(usize, &[u8]), Error> {
        let zones = self.zones()?;
        let zone = zones
            .get(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?;
        let mut at = 0;
        for section in &self.body.sections {
            if section.is(section::STK4) && stroke_gid(section) == Some(zone.stroke_gid) {
                return Ok((at + section::HEADER4_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        Err(ParseError::AssertFail(format!(
            "zone {index} names stroke {}, which the file does not contain",
            zone.stroke_gid
        ))
        .into())
    }
}

pub fn from_bytes(bytes: &[u8]) -> Result<Cbin<Sample>, Error> {
    read_from(&mut std::io::Cursor::new(bytes))
}

/// The global id a `stk` payload leads with.
fn stroke_id(section: &Section) -> Option<u32> {
    let b = section.payload.get(0..4)?;
    Some(u32::from_be_bytes(b.try_into().ok()?))
}

/// The global id a v3/v4 `stk` payload leads with. Unlike [`stroke_id`]'s
/// narrow counterpart it is compared whole: a wide zone record stores the same
/// u32.
fn stroke_gid(section: &section::Section4) -> Option<u32> {
    let b = section.payload.get(0..4)?;
    Some(u32::from_be_bytes(b.try_into().ok()?))
}

/// Whether a stroke is the one a zone record names.
///
/// ⚠️ The record holds one byte and the stroke holds a u32, so the pairing is modulo
/// 256. Library instruments whose ids run past 255 exist in both narrow chains, and
/// comparing the whole u32 hands those files a zone table that does not read.
fn names_stroke(id: u32, named: u8) -> bool {
    id as u8 == named
}

impl Cbin<Sample> {
    /// Serializes, recomputing the checksum over the body it just produced.
    pub fn to_bytes(&self) -> Result<Vec<u8>, Error> {
        let mut out = std::io::Cursor::new(Vec::new());
        self.write_to(&mut out)?;
        Ok(out.into_inner())
    }

    /// Instrument name, as the Nord display shows it.
    ///
    /// The editor composes this from separate Main, Sub and Aux fields joined with `_`,
    /// so an empty Sub shows up as a doubled underscore rather than a typo.
    ///
    /// ⚠️ Empty on [`Chain::Early`], whose 18-byte `hdr` has no name field at all —
    /// those instruments carry no name and [`Self::set_name`] has nowhere to put one.
    /// Ask [`Self::chain`] before reporting the empty string as the name.
    pub fn name(&self) -> Result<String, Error> {
        Ok(StringField::NAME.read(&self.hdr()?.payload))
    }

    /// Renames in place, NUL-padding the rest of the field.
    pub fn set_name(&mut self, name: &str) -> Result<(), Error> {
        let hdr = section::find_mut(&mut self.body.sections, section::HDR)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()))?;
        StringField::NAME.write(&mut hdr.payload, name)
    }

    /// Which narrow chain this body's sections form, from the `map` section's own
    /// version. An unknown one refuses rather than decoding on a guess; the section
    /// chain, the name and the checksum still read.
    pub fn chain(&self) -> Result<Chain, Error> {
        Ok(Chain::from_map_version(self.map()?.version)?)
    }

    /// Keyboard zones, high to low.
    pub fn zones(&self) -> Result<Vec<Zone>, Error> {
        Ok(zone::read(self.chain()?, &self.map()?.payload)?)
    }

    /// The instrument's default sound preset — nine enum-quantised bytes.
    pub fn sty(&self) -> Result<StyV2, Error> {
        let s = section::find(&self.body.sections, section::STY)
            .ok_or_else(|| ParseError::AssertFail("no sty section".into()))?;
        if s.version != sty::VERSION_V2 {
            return Err(ParseError::AssertFail(format!(
                "sty section version {} has no preset layout derived from a specimen",
                s.version
            ))
            .into());
        }
        Ok(StyV2::parse(&s.payload)?)
    }

    /// Sets one zone's top note. The strokes are untouched.
    pub fn set_zone_top_note(&mut self, index: usize, note: u8) -> Result<(), Error> {
        let chain = self.chain()?;
        let map = section::find_mut(&mut self.body.sections, section::MAP)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()))?;
        zone::set_top_note(chain, &mut map.payload, index, note)?;
        Ok(())
    }

    /// The keyboard map: the instrument's gain and detune, and one record per
    /// MIDI note.
    pub fn key_table(&self) -> Result<KeyTable, Error> {
        // Both narrow chains carry the same table ahead of their zone tables; a `map`
        // this crate does not recognise may carry something else.
        self.chain()?;
        Ok(KeyTable::read(&self.map()?.payload)?)
    }

    /// Replaces the keyboard map. The zone table and the strokes are untouched.
    pub fn set_key_table(&mut self, table: &KeyTable) -> Result<(), Error> {
        // As in `key_table`: the table is shared, an unrecognised `map` is refused.
        self.chain()?;
        let map = section::find_mut(&mut self.body.sections, section::MAP)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()))?;
        table.write(&mut map.payload)?;
        Ok(())
    }

    /// One stroke per zone, **in [`Self::zones`] order** — which is not file order.
    ///
    /// Each zone names its stroke by id, and only instruments built in a single editor
    /// pass have those ids running parallel to the sections. Zipping this against
    /// `zones()` is therefore safe; indexing it as "the nth `stk` section" is not.
    pub fn strokes(&self) -> Result<Vec<Stroke>, Error> {
        let zones = self.zones()?;
        let by_id = self.strokes_in_file_order()?;
        zones
            .iter()
            .map(|z| {
                by_id
                    .iter()
                    .find(|(id, _)| names_stroke(*id, z.stroke_id))
                    .map(|(_, s)| *s)
                    .ok_or_else(|| {
                        ParseError::AssertFail(format!(
                            "zone reaching up to note {} names stroke {}, which the file \
                             does not contain",
                            z.top_note, z.stroke_id
                        ))
                        .into()
                    })
            })
            .collect()
    }

    /// Every stroke's encoded stream with its offset from the start of the body, in
    /// file order.
    ///
    /// The offset is the base the stroke's own [`codec::Directory`] is written
    /// against, so a caller checking those pointers needs this pairing rather than
    /// the payload alone.
    pub fn stroke_streams(&self) -> Vec<(usize, &[u8])> {
        let mut at = 0;
        let mut out = Vec::new();
        for section in &self.body.sections {
            if section.is(section::STK) {
                out.push((at + section::HEADER_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        out
    }

    /// One zone's encoded stream, in [`Self::zones`] order, ready for
    /// [`codec::decode`].
    ///
    /// Paired by stroke id like [`Self::strokes`], so it is safe on library content
    /// that the editor did not build in a single pass.
    pub fn zone_stream(&self, index: usize) -> Result<(usize, &[u8]), Error> {
        let zones = self.zones()?;
        let zone = zones
            .get(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?;
        let wanted = zone.stroke_id;
        let mut at = 0;
        for section in &self.body.sections {
            if section.is(section::STK)
                && stroke_id(section).is_some_and(|id| names_stroke(id, wanted))
            {
                return Ok((at + section::HEADER_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        Err(ParseError::AssertFail(format!(
            "zone {index} names stroke {wanted}, which the file does not contain"
        ))
        .into())
    }

    /// Every stroke with the global id it carries, in the order the sections appear.
    ///
    /// The header length depends on a stroke's *position in the file*, so the read has
    /// to happen here, before anything reorders them.
    fn strokes_in_file_order(&self) -> Result<Vec<(u32, Stroke)>, Error> {
        // The first stroke's header is the remainder of a preamble it shares with
        // these two, so their sizes are what fixes where its audio starts. The
        // pre-2.0 chain has no `cat` and a budget that is larger by as much.
        let chain = self.chain()?;
        let map_len = self.map()?.payload.len();
        let cat_len =
            section::find(&self.body.sections, section::CAT).map_or(0, |s| s.payload.len());
        self.stroke_sections()
            .enumerate()
            .map(|(i, s)| {
                let id = s
                    .payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    .ok_or_else(|| {
                        ParseError::AssertFail(format!(
                            "stroke {i} is {} bytes, too short for its id",
                            s.payload.len()
                        ))
                    })?;
                Ok((id, stroke::read(&s.payload, chain, i, cat_len, map_len)?))
            })
            .collect()
    }

    /// Retunes one zone by moving the note its sample plays untransposed at.
    ///
    /// `index` is into [`Self::zones`], matching [`Self::set_zone_top_note`] — so the
    /// stroke it reaches is the one that zone names, not the nth section. The two are
    /// the same file order only for instruments the editor built in a single pass.
    pub fn set_root_key(&mut self, index: usize, note: u8) -> Result<(), Error> {
        let zones = self.zones()?;
        let zone = zones
            .get(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?;
        let wanted = zone.stroke_id;
        let section = self
            .body
            .sections
            .iter_mut()
            .filter(|s| s.is(section::STK))
            .find(|s| stroke_id(s).is_some_and(|id| names_stroke(id, wanted)))
            .ok_or_else(|| {
                ParseError::AssertFail(format!(
                    "zone {index} names stroke {wanted}, which the file does not contain"
                ))
            })?;
        stroke::set_root_key(&mut section.payload, note)?;
        Ok(())
    }

    /// Category labels, as stored in `cat`: length-prefixed strings.
    pub fn categories(&self) -> Vec<String> {
        let Some(cat) = section::find(&self.body.sections, section::CAT) else {
            return Vec::new();
        };
        let mut out = Vec::new();
        let mut i = 0;
        while i < cat.payload.len() {
            let len = cat.payload[i] as usize;
            let from = i + 1;
            // A length running past the end means this is not a string here; the
            // section holds a few leading bytes before the labels start.
            match cat.payload.get(from..from + len) {
                Some(s) if len > 0 && s.iter().all(|&b| (0x20..0x7f).contains(&b)) => {
                    out.push(String::from_utf8_lossy(s).into_owned());
                    i = from + len;
                }
                _ => i += 1,
            }
        }
        out
    }

    fn stroke_sections(&self) -> impl Iterator<Item = &Section> {
        self.body.sections.iter().filter(|s| s.is(section::STK))
    }

    fn hdr(&self) -> Result<&Section, Error> {
        section::find(&self.body.sections, section::HDR)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()).into())
    }

    fn map(&self) -> Result<&Section, Error> {
        section::find(&self.body.sections, section::MAP)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()).into())
    }
}

impl fmt::Debug for Sample {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Sample")
            .field("sections", &self.sections)
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The content version tracks the library release and both chains ship several,
    /// so the `map` section's own version is the gate.
    #[test]
    fn the_map_version_selects_the_chain_and_an_unknown_one_refuses() {
        assert_eq!(Chain::from_map_version(9).unwrap(), Chain::Early);
        assert_eq!(Chain::from_map_version(10).unwrap(), Chain::Library2);
        assert!(Chain::from_map_version(11).is_err());
    }

    #[test]
    fn an_unknown_map_version_cannot_use_the_zone_setter() {
        let crate::Sample::V2(mut sample) =
            encode::instrument(&[0i16; encode::MIN_FRAMES], &encode::Options::new("Test")).unwrap()
        else {
            panic!("the default options build the narrow chain");
        };
        let map = section::find_mut(&mut sample.body.sections, section::MAP).unwrap();
        map.version = keymap::VERSION + 1;
        let before = map.payload.clone();
        assert!(sample.zones().is_err());
        assert!(sample.set_zone_top_note(0, 60).is_err());
        assert_eq!(sample.map().unwrap().payload, before);
    }

    #[test]
    fn an_unknown_map_version_cannot_use_the_keyboard_table() {
        let crate::Sample::V2(mut sample) =
            encode::instrument(&[0i16; encode::MIN_FRAMES], &encode::Options::new("Test")).unwrap()
        else {
            panic!("the default options build the narrow chain");
        };
        let map = section::find_mut(&mut sample.body.sections, section::MAP).unwrap();
        map.version = keymap::VERSION + 1;
        let before = map.payload.clone();
        assert!(sample.key_table().is_err());
        assert!(sample.set_key_table(&KeyTable::NEUTRAL).is_err());
        assert_eq!(sample.map().unwrap().payload, before);
    }

    #[test]
    fn a_name_field_holds_its_whole_span_less_the_terminator() {
        assert_eq!(MAX_NAME_LEN, 31);
        assert_eq!(MAX_NAME_V3_LEN, 65);
    }

    #[test]
    fn a_rename_leaves_nothing_of_the_name_it_replaced() {
        for field in [StringField::NAME, StringField::NAME_V3] {
            let mut payload = vec![0u8; field.next];
            let long = "M".repeat(field.capacity());
            field.write(&mut payload, &long).unwrap();
            field.write(&mut payload, "Short").unwrap();
            assert_eq!(field.read(&payload), "Short");
            assert!(payload[field.at + 5..field.next].iter().all(|&b| b == 0));
        }
    }

    #[test]
    fn a_name_one_byte_past_the_field_is_refused() {
        let field = StringField::NAME;
        let mut payload = vec![0xffu8; field.next + 8];
        let error = field
            .write(&mut payload, &"M".repeat(field.capacity() + 1))
            .unwrap_err()
            .to_string();
        assert!(error.contains("at most 31 bytes"), "{error}");
        assert!(payload[field.at..].iter().all(|&b| b == 0xff));
    }

    #[test]
    fn a_name_filling_its_field_stops_at_the_field_that_follows() {
        let field = StringField::NAME_V3;
        let mut payload = vec![0u8; 112];
        payload[field.next..field.next + 7].copy_from_slice(b"KG mono");
        let long = "M".repeat(field.capacity());
        field.write(&mut payload, &long).unwrap();
        assert_eq!(field.read(&payload), long);
        assert_eq!(nul_terminated(&payload[field.next..]), "KG mono");
    }

    /// The oldest narrow `hdr` is 18 bytes and stops inside the name field.
    #[test]
    fn a_header_with_no_name_field_reads_back_empty_and_refuses_a_rename() {
        assert_eq!(StringField::NAME.read(&[0u8; 18]), "");
        assert_eq!(StringField::NAME.read(&[]), "");
        assert!(StringField::NAME.write(&mut [0u8; 18], "Name").is_err());
    }
}