nord-format 0.4.0

Read and write Clavia / Nord keyboard file formats — programs, samples, set lists, settings, backups — with byte-exact round-trips
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
//! 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`.
//!
//! Both container generations occur: across the corpus every v2 specimen is type 0 and
//! every v4 is type 1, while v3 is split. The container handles the difference; the
//! chain is the same.
//!
//! **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, v2 only.

/// 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 section;
pub mod stroke;
pub mod zone;

pub use section::Section;
pub use stroke::Stroke;
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;

/// Content version of the first Sample Library whose v2 layout this reader decodes.
///
/// The number tracks the *library release*, not the codec, so the versions below this
/// are older libraries rather than older codecs — 8 above all, plus a 4/5/100/140/150
/// tail we hold no specimen of. They are still `NWS`-chain files; only what sits
/// inside the sections differs.
pub const LIBRARY_2_VERSION: u32 = 200;

/// 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; independent interop projects
/// report the number tracks the library release, not the codec) — so the gate
/// is "at least 300", not "exactly 2xx".
#[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),
        }
    }
}

/// Offset of the instrument name within the `hdr` payload.
const NAME_AT: usize = 12;

/// Longest name this writer will emit.
///
/// The field is fixed-width and NUL-padded — a 4-character and a 14-character name give
/// the same file length — but only 14 bytes have ever been observed in use, and what
/// follows the name inside `hdr` is unmapped. Writing a longer one risks overwriting a
/// field we cannot see, so refuse instead. Reading is unrestricted.
pub const MAX_NAME_LEN: usize = 14;

/// 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> {
        Ok(Sample {
            sections: section::read_chain(r)?,
        })
    }

    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> {
        Ok(SampleV3 {
            sections: section::read_chain4(r)?,
        })
    }

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

/// Offset of the main name within the v3/v4 `hdr` payload.
const NAME_V3_AT: usize = 10;

/// End of the main-name field: the sub-name field starts here. The two fields
/// are what the filename convention joins — `Bass Clarinet 2` + `KG  mono` →
/// `Bass Clarinet 2_KG  mono 3.11`. Inferred from specimens; not confirmed on
/// hardware.
const NAME_V3_SUB_AT: usize = 76;

/// Longest main name this writer will emit on the wide chain.
///
/// The whole field is writable here, unlike [`MAX_NAME_LEN`]: what follows it is
/// the sub-name rather than unmapped bytes, so the bound is the field itself.
pub const MAX_NAME_V3_LEN: usize = NAME_V3_SUB_AT - NAME_V3_AT;

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())
    }

    fn hdr_field(&self, from: usize, to: Option<usize>) -> Result<String, Error> {
        let hdr = self.hdr()?;
        let field = match to {
            Some(to) => hdr.payload.get(from..to),
            None => hdr.payload.get(from..),
        }
        .ok_or_else(|| {
            ParseError::AssertFail(format!("hdr section is {} bytes", hdr.payload.len()))
        })?;
        let end = field.iter().position(|&b| b == 0).unwrap_or(field.len());
        Ok(String::from_utf8_lossy(&field[..end]).into_owned())
    }

    /// The instrument's main name.
    pub fn name(&self) -> Result<String, Error> {
        self.hdr_field(NAME_V3_AT, Some(NAME_V3_SUB_AT))
    }

    /// The sub name — the string after the `_` in the vendor's filenames.
    /// Empty on files that carry none.
    pub fn sub_name(&self) -> Result<String, Error> {
        self.hdr_field(NAME_V3_SUB_AT, None)
    }

    /// 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 (s.payload.get(0..4), s.payload.get(5)) {
                (Some(gid), Some(&root)) => Ok((u32::from_be_bytes(gid.try_into().unwrap()), 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 — high to low except `map` v14, which
    /// stores low to high. 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())
    }

    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> {
        if name.len() > MAX_NAME_V3_LEN {
            return Err(ParseError::OutOfBounds {
                value: format!("{name:?} ({} bytes)", name.len()),
                bound: format!("a name of at most {MAX_NAME_V3_LEN} bytes"),
            }
            .into());
        }
        let hdr = section::find_mut4(&mut self.body.sections, section::HDR4)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()))?;
        let field = hdr
            .payload
            .get_mut(NAME_V3_AT..NAME_V3_SUB_AT)
            .ok_or_else(|| ParseError::AssertFail("hdr section is too short for a name".into()))?;
        field.fill(0);
        field[..name.len()].copy_from_slice(name.as_bytes());
        Ok(())
    }

    /// 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)
                    && s.payload
                        .get(0..4)
                        .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                        == 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)
                && section
                    .payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    == 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))
}

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.
    pub fn name(&self) -> Result<String, Error> {
        let hdr = self.hdr()?;
        let from = hdr.payload.get(NAME_AT..).ok_or_else(|| {
            ParseError::AssertFail(format!("hdr section is {} bytes", hdr.payload.len()))
        })?;
        let end = from.iter().position(|&b| b == 0).unwrap_or(from.len());
        Ok(String::from_utf8_lossy(&from[..end]).into_owned())
    }

    /// Renames in place, NUL-padding the rest of the field.
    pub fn set_name(&mut self, name: &str) -> Result<(), Error> {
        if name.len() > MAX_NAME_LEN {
            return Err(ParseError::OutOfBounds {
                value: format!("{name:?} ({} bytes)", name.len()),
                bound: format!("a name of at most {MAX_NAME_LEN} bytes"),
            }
            .into());
        }
        let hdr = section::find_mut(&mut self.body.sections, section::HDR)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()))?;
        let field = hdr
            .payload
            .get_mut(NAME_AT..NAME_AT + MAX_NAME_LEN)
            .ok_or_else(|| ParseError::AssertFail("hdr section is too short for a name".into()))?;
        field.fill(0);
        field[..name.len()].copy_from_slice(name.as_bytes());
        Ok(())
    }

    /// Pre-2.0 libraries use a different zone layout; their section chain, name, and
    /// checksum remain readable.
    fn require_known_layout(&self) -> Result<(), Error> {
        if self.header.version < LIBRARY_2_VERSION {
            return Err(ParseError::AssertFail(format!(
                "content version {} predates Sample Library 2.0 and lays out its zone \
                 table differently; only the section chain and name are decoded",
                self.header.version
            ))
            .into());
        }
        Ok(())
    }

    /// Keyboard zones, high to low.
    pub fn zones(&self) -> Result<Vec<Zone>, Error> {
        self.require_known_layout()?;
        Ok(zone::read(&self.map()?.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> {
        self.require_known_layout()?;
        let map = section::find_mut(&mut self.body.sections, section::MAP)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()))?;
        zone::set_top_note(&mut map.payload, index, note)?;
        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> {
        self.require_known_layout()?;
        let zones = self.zones()?;
        let by_id = self.strokes_in_file_order()?;
        zones
            .iter()
            .map(|z| {
                by_id
                    .iter()
                    .find(|(id, _)| *id == u32::from(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 = u32::from(zone.stroke_id);
        let mut at = 0;
        for section in &self.body.sections {
            if section.is(section::STK)
                && section
                    .payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    == Some(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.
        let map_len = self.map()?.payload.len();
        let cat_len = section::find(&self.body.sections, section::CAT)
            .map(|s| s.payload.len())
            .ok_or_else(|| ParseError::AssertFail("no cat section".into()))?;
        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, 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 = u32::from(zone.stroke_id);
        let section = self
            .body
            .sections
            .iter_mut()
            .filter(|s| s.is(section::STK))
            .find(|s| {
                s.payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    == Some(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::*;

    #[test]
    fn pre_v2_layout_cannot_use_the_modern_zone_setter() {
        let mut sample = Cbin {
            header: Header::new(FORMAT, (0, 0), LIBRARY_2_VERSION - 1),
            body: Sample { sections: vec![] },
        };
        let error = sample.set_zone_top_note(0, 60).unwrap_err().to_string();
        assert!(error.contains("predates Sample Library 2.0"), "{error}");
    }
}