Skip to main content

shuyi_assets/
audio.rs

1//! The audio manifest: every sound and music cue a game can play, the
2//! music pools, and the credits for every third-party work. Screens name
3//! cues; the platform layer plays the files.
4//!
5//! The game says where the files are ([`Files`]): sounds are usually in
6//! the bundle, and music in a folder shipped next to the game, which the
7//! game itself may not be able to read. [`from_source`] checks what it can
8//! see, including each track's `length_ms` against its file
9//! ([`ogg_length_ms`]).
10//!
11//! As formats (ADR-0007): a sound file's header is the [`Sound`] kind
12//! ([`Wav`], [`OggVorbis`]), a music track's the [`MusicTrack`] kind
13//! ([`OggVorbisTrack`]), and the manifest is read by
14//! [`AudioManifestRon`].
15use crate::prelude::*;
16
17mod formats;
18
19use alloc::collections::{BTreeMap, BTreeSet};
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{ContentError, line_of};
24use crate::ron::parse_ron;
25
26pub use formats::{
27    AudioManifestRon, MAX_SOUND_BYTES, MusicTrack, OggVorbis, OggVorbisTrack, Sound, Wav,
28};
29
30/// Licenses a credit may have (game ADR-0013, game ADR-0027). `Own` is work we made.
31pub const LICENSES: [&str; 4] = ["CC0-1.0", "CC-BY-3.0", "CC-BY-4.0", OWN];
32
33/// The license of work we made ourselves.
34pub const OWN: &str = "Own";
35
36/// Highest cue volume, in percent of the file's own loudness.
37pub const MAX_VOLUME: u8 = 100;
38
39/// How far a music cue's `length_ms` may be from its file's real length,
40/// in milliseconds.
41pub const LENGTH_TOLERANCE_MS: u32 = 20;
42
43/// The validated audio manifest. Build it with `default()` and assign
44/// its fields (ADR-0039).
45#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[non_exhaustive]
47pub struct AudioManifest {
48    /// How long a music track fades out on a switch or a stop, in
49    /// milliseconds (`audio.md`: 0.5 s, *tunable*): the default
50    /// [`MusicSwitch`]'s `out_ms`.
51    pub music_fade_ms: u32,
52    /// How long the next track fades in, in milliseconds (0, the file's
53    /// default: it starts at full gain): the default switch's `in_ms`.
54    pub music_fade_in_ms: u32,
55    /// Whether the next track starts as the old one begins to fade (a
56    /// crossfade) rather than once it has stopped (`false`, the file's
57    /// default): the default switch's `overlap`.
58    pub music_overlap: bool,
59    /// Sound cues by id.
60    pub sounds: BTreeMap<String, SoundCue>,
61    /// Music cues by id.
62    pub music: BTreeMap<String, MusicCue>,
63    /// Music pools by id: the music cues one is picked from.
64    pub pools: BTreeMap<String, Vec<String>>,
65    /// Credits by id.
66    pub credits: BTreeMap<String, Credit>,
67}
68
69impl AudioManifest {
70    /// How one music track gives way to the next when a request names no
71    /// switch: the manifest's fade-out, fade-in and overlap.
72    #[must_use]
73    pub const fn music_switch(&self) -> MusicSwitch {
74        MusicSwitch::new(
75            self.music_fade_ms,
76            self.music_fade_in_ms,
77            self.music_overlap,
78        )
79    }
80}
81
82/// How one music track gives way to the next (ADR-0054): the old one
83/// fades out over `out_ms`, and the new one fades in over `in_ms`,
84/// starting as the old one begins to fade (`overlap`, a crossfade) or once
85/// it has stopped. Build it with [`new`](Self::new),
86/// [`after_fade`](Self::after_fade) or [`crossfade`](Self::crossfade)
87/// (ADR-0039).
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct MusicSwitch {
91    /// How long the old track fades out, in milliseconds.
92    pub out_ms: u32,
93    /// How long the new track fades in, in milliseconds (0: it starts at
94    /// full gain).
95    pub in_ms: u32,
96    /// Whether the new track starts when the old one begins to fade (a
97    /// crossfade), or after it has stopped.
98    pub overlap: bool,
99}
100
101impl MusicSwitch {
102    /// A fade-out of `out_ms`, a fade-in of `in_ms`, overlapping or not.
103    #[must_use]
104    pub const fn new(out_ms: u32, in_ms: u32, overlap: bool) -> Self {
105        Self {
106            out_ms,
107            in_ms,
108            overlap,
109        }
110    }
111
112    /// The old track fades out over `out_ms`, then the new one starts at
113    /// full gain: the engine's switch before crossfades.
114    #[must_use]
115    pub const fn after_fade(out_ms: u32) -> Self {
116        Self::new(out_ms, 0, false)
117    }
118
119    /// A crossfade: the new track fades in over `ms` while the old one
120    /// fades out over the same time.
121    #[must_use]
122    pub const fn crossfade(ms: u32) -> Self {
123        Self::new(ms, ms, true)
124    }
125}
126
127/// A sound effect: one of its files plays each time. Build it with
128/// [`SoundCue::new`] (ADR-0039).
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[non_exhaustive]
131pub struct SoundCue {
132    /// The variants, relative to `base/audio/`; one is picked at random
133    /// each time the cue plays. At least one.
134    pub files: Vec<String>,
135    /// Volume in percent (0–100).
136    pub volume: u8,
137    /// Who made it.
138    pub credit: CreditRef,
139    /// Whether it loops until it is stopped (`false`, the file's default:
140    /// it plays once). A looped cue is started with a handle
141    /// (`AudioQueue::start_sound` in `shuyi-ui`), which stops it.
142    pub looped: bool,
143}
144
145impl SoundCue {
146    /// The sound of `files` (one picked each time), at `volume` percent,
147    /// made by `credit`, played once.
148    #[must_use]
149    pub const fn new(files: Vec<String>, volume: u8, credit: CreditRef) -> Self {
150        Self {
151            files,
152            volume,
153            credit,
154            looped: false,
155        }
156    }
157
158    /// The same cue, looped until it is stopped or not.
159    #[must_use]
160    pub const fn with_looped(mut self, looped: bool) -> Self {
161        self.looped = looped;
162        self
163    }
164}
165
166/// A music track.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct MusicCue {
169    /// The track, relative to the `music/` folder.
170    pub file: String,
171    /// Volume in percent (0–100).
172    pub volume: u8,
173    /// Whether it loops (the default) or plays once.
174    pub looped: bool,
175    /// How long the track plays, in milliseconds (never 0): where the music
176    /// clock wraps a looped track (game ADR-0037). The game can't measure it
177    /// (the player library gives no length), so it is data, checked
178    /// against the file by a test.
179    pub length_ms: u32,
180    /// Who made it.
181    pub credit: CreditRef,
182}
183
184fn yes() -> bool {
185    true
186}
187
188/// Who made a sound or a track.
189#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
190pub enum CreditRef {
191    /// We made it.
192    Own,
193    /// A third-party work: the id of its entry in `credits`.
194    Credit(String),
195    /// Several third-party works, e.g. a sound whose variants come from
196    /// different authors: their ids in `credits`.
197    Credits(Vec<String>),
198}
199
200impl CreditRef {
201    /// The ids in `credits` this names (none for [`CreditRef::Own`]).
202    pub fn ids(&self) -> &[String] {
203        match self {
204            Self::Own => &[],
205            Self::Credit(id) => core::slice::from_ref(id),
206            Self::Credits(ids) => ids,
207        }
208    }
209}
210
211/// One third-party work, for the credits screen (`audio.md` rule 3).
212#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
213pub struct Credit {
214    /// The work's title.
215    pub title: String,
216    /// Its author, as they want to be credited.
217    pub author: String,
218    /// The page it came from.
219    pub source: String,
220    /// One of [`LICENSES`].
221    pub license: String,
222    /// Free tags (`music`, `sfx`, the cue, a mood, a place …).
223    pub tags: Vec<String>,
224    /// Which file or version we use, e.g. "V2 file".
225    pub note: String,
226}
227
228#[derive(Deserialize)]
229#[serde(deny_unknown_fields)]
230struct RawFile {
231    music_fade_ms: u32,
232    #[serde(default)]
233    music_fade_in_ms: u32,
234    #[serde(default)]
235    music_overlap: bool,
236    sounds: Vec<RawSound>,
237    music: Vec<RawMusic>,
238    pools: Vec<RawPool>,
239    credits: Vec<RawCredit>,
240}
241
242#[derive(Deserialize)]
243#[serde(deny_unknown_fields)]
244struct RawSound {
245    id: String,
246    files: Vec<String>,
247    volume: u8,
248    credit: CreditRef,
249    #[serde(default)]
250    looped: bool,
251}
252
253#[derive(Deserialize)]
254#[serde(deny_unknown_fields)]
255struct RawMusic {
256    id: String,
257    file: String,
258    volume: u8,
259    #[serde(default = "yes")]
260    looped: bool,
261    length_ms: u32,
262    credit: CreditRef,
263}
264
265#[derive(Deserialize)]
266#[serde(deny_unknown_fields)]
267struct RawCredit {
268    id: String,
269    #[serde(default)]
270    title: String,
271    #[serde(default)]
272    author: String,
273    #[serde(default)]
274    source: String,
275    license: String,
276    #[serde(default)]
277    tags: Vec<String>,
278    #[serde(default)]
279    note: String,
280}
281
282#[derive(Deserialize)]
283#[serde(deny_unknown_fields)]
284struct RawPool {
285    id: String,
286    music: Vec<String>,
287}
288
289/// A file's contents, or `None` if it's missing.
290pub type Bytes = Option<alloc::borrow::Cow<'static, [u8]>>;
291
292/// How to read the referenced files, and how errors name them. `music` is
293/// `None` where the music folder can't be seen (the game itself, which
294/// does no file I/O). Build it with [`Files::new`] (ADR-0039).
295#[non_exhaustive]
296pub struct Files<'a> {
297    /// Reads a sound file, by its name in the manifest (e.g. `sfx/x.wav`).
298    pub sound: &'a dyn Fn(&str) -> Bytes,
299    /// A sound file's name as errors show it, by its name in the manifest
300    /// (e.g. `base/audio/sfx/x.wav`).
301    pub sound_shown: &'a dyn Fn(&str) -> String,
302    /// Whether this build leaves a sound file out on purpose, by its name
303    /// in the manifest (a bundle's `left_out`): it is then not checked.
304    pub sound_left_out: &'a dyn Fn(&str) -> bool,
305    /// The music folder's name as errors show it (e.g. `music`).
306    pub music_dir: &'a str,
307    /// Reads a file in the music folder.
308    pub music: Option<&'a dyn Fn(&str) -> Bytes>,
309}
310
311impl<'a> Files<'a> {
312    /// Sound files read by `sound`, named in errors by `sound_shown`, left
313    /// out where `sound_left_out` says, and a music folder shown as
314    /// `music_dir` that can't be seen (`music` is `None`:
315    /// [`with_music`](Self::with_music) changes it).
316    #[must_use]
317    pub const fn new(
318        sound: &'a dyn Fn(&str) -> Bytes,
319        sound_shown: &'a dyn Fn(&str) -> String,
320        sound_left_out: &'a dyn Fn(&str) -> bool,
321        music_dir: &'a str,
322    ) -> Self {
323        Self {
324            sound,
325            sound_shown,
326            sound_left_out,
327            music_dir,
328            music: None,
329        }
330    }
331
332    /// The files with the music folder read by `music`: usable in a
333    /// `const` item.
334    #[must_use]
335    pub const fn with_music(self, music: Option<&'a dyn Fn(&str) -> Bytes>) -> Self {
336        Self { music, ..self }
337    }
338}
339
340/// Where a sound file goes in a package whose target bakes its audio (the
341/// `.pdx`): `sfx/x.wav` is `audio/sfx/x` there.
342pub const BAKED_SOUND_DIR: &str = "audio";
343
344/// Where a music file goes in a package whose target bakes its audio.
345pub const BAKED_MUSIC_DIR: &str = "music";
346
347/// `file` without its extension, as a device that bakes its audio opens it.
348#[must_use]
349pub fn without_extension(file: &str) -> &str {
350    let name = file.rfind('/').unwrap_or(0);
351    match file[name..].rfind('.') {
352        Some(dot) => &file[..name + dot],
353        None => file,
354    }
355}
356
357/// The sample rate every file must have: quad-snd (native) plays at
358/// 44.1 kHz and resamples anything else crudely (game ADR-0026).
359pub const SAMPLE_RATE: u32 = 44_100;
360
361/// Why `bytes` (a file named `name`) isn't something the game can play:
362/// its content must match its extension (OGG Vorbis for `.ogg`, PCM or
363/// float WAV for `.wav`), with one or two channels at [`SAMPLE_RATE`].
364/// quad-snd panics on native, and never finishes loading on the web, on a
365/// file it can't read, so this runs before any file ships.
366pub fn format_problem(name: &str, bytes: &[u8]) -> Option<String> {
367    let format = if has_extension(name, &["wav"]) {
368        wav_format(bytes).ok_or("not a PCM WAV file")
369    } else {
370        vorbis_format(bytes).ok_or("not an OGG Vorbis file")
371    };
372    match format {
373        Err(why) => Some(why.to_owned()),
374        Ok((channels, rate)) => playable_problem(channels, rate),
375    }
376}
377
378/// Why a sound of `channels` channels at `rate` Hz isn't something the
379/// game can play, if it isn't: it must be mono or stereo, at
380/// [`SAMPLE_RATE`].
381fn playable_problem(channels: u16, rate: u32) -> Option<String> {
382    if !(1..=2).contains(&channels) {
383        Some(format!("{channels} channels; must be mono or stereo"))
384    } else if rate != SAMPLE_RATE {
385        Some(format!("{rate} Hz; must be {SAMPLE_RATE} Hz"))
386    } else {
387        None
388    }
389}
390
391fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
392    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
393}
394
395fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
396    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
397}
398
399/// A WAV's (channels, sample rate), from its `fmt ` chunk.
400fn wav_format(bytes: &[u8]) -> Option<(u16, u32)> {
401    if bytes.get(..4)? != b"RIFF" || bytes.get(8..12)? != b"WAVE" {
402        return None;
403    }
404    let mut at = 12;
405    loop {
406        let id = bytes.get(at..at + 4)?;
407        let size = usize::try_from(u32_at(bytes, at + 4)?).ok()?;
408        if id == b"fmt " {
409            // 1 = integer PCM, 3 = float: what quad-snd's decoder reads.
410            let encoding = u16_at(bytes, at + 8)?;
411            return [1, 3]
412                .contains(&encoding)
413                .then_some((u16_at(bytes, at + 10)?, u32_at(bytes, at + 12)?));
414        }
415        at = at.checked_add(size.checked_add(8 + size % 2)?)?;
416    }
417}
418
419/// An OGG Vorbis file's (channels, sample rate), from the identification
420/// header in its first page.
421fn vorbis_format(bytes: &[u8]) -> Option<(u16, u32)> {
422    if bytes.get(..4)? != b"OggS" {
423        return None;
424    }
425    let packet = 27 + usize::from(*bytes.get(26)?);
426    if bytes.get(packet..packet + 7)? != b"\x01vorbis" {
427        return None;
428    }
429    let channels = u16::from(*bytes.get(packet + 11)?);
430    Some((channels, u32_at(bytes, packet + 12)?))
431}
432
433/// How long an OGG Vorbis file plays, in milliseconds (to the nearest): the
434/// granule position of its last page, which for Vorbis is the number of
435/// samples in the stream, over the sample rate in its header. `None` if
436/// `bytes` isn't a whole OGG Vorbis file.
437pub fn ogg_length_ms(bytes: &[u8]) -> Option<u32> {
438    let (_, rate) = vorbis_format(bytes)?;
439    let rate = u64::from(rate);
440    let samples = last_granule(bytes)?;
441    let ms = samples
442        .checked_mul(1000)?
443        .checked_add(rate / 2)?
444        .checked_div(rate)?;
445    u32::try_from(ms).ok()
446}
447
448/// The granule position of the Ogg page that ends `bytes`. The letters
449/// `OggS` can also turn up inside a page's audio data, so the page must
450/// run exactly to the end of the file. `None` too if no packet ends on
451/// that page (its position is then all ones).
452fn last_granule(bytes: &[u8]) -> Option<u64> {
453    let ends_file = |&at: &usize| {
454        bytes.get(at..at + 4) == Some(b"OggS")
455            && ogg_page_len(bytes, at).and_then(|len| at.checked_add(len)) == Some(bytes.len())
456    };
457    let at = (0..bytes.len()).rev().find(ends_file)?;
458    let granule = u64::from_le_bytes(bytes.get(at + 6..at + 14)?.try_into().ok()?);
459    (granule != u64::MAX).then_some(granule)
460}
461
462/// The length of the Ogg page starting at `at`: its 27-byte header, its
463/// segment table and the segments the table lists.
464fn ogg_page_len(bytes: &[u8], at: usize) -> Option<usize> {
465    let segments = usize::from(*bytes.get(at + 26)?);
466    let table = bytes.get(at + 27..at + 27 + segments)?;
467    let body: usize = table.iter().map(|&s| usize::from(s)).sum();
468    Some(27 + segments + body)
469}
470
471/// Parses and validates manifest `source`, attributing errors to `file`.
472pub fn from_source(
473    file: &str,
474    source: &str,
475    files: &Files<'_>,
476) -> Result<AudioManifest, Vec<ContentError>> {
477    let raw: RawFile = parse_ron(file, source).map_err(|e| vec![e])?;
478    let mut check = Check {
479        file,
480        source,
481        files,
482        errors: Vec::new(),
483        cue_ids: BTreeSet::new(),
484        manifest: AudioManifest {
485            music_fade_ms: raw.music_fade_ms,
486            music_fade_in_ms: raw.music_fade_in_ms,
487            music_overlap: raw.music_overlap,
488            ..AudioManifest::default()
489        },
490    };
491    for c in raw.credits {
492        check.credit(c);
493    }
494    for s in raw.sounds {
495        check.sound(s);
496    }
497    for m in raw.music {
498        check.music(m);
499    }
500    for p in raw.pools {
501        check.pool(p);
502    }
503    if check.errors.is_empty() {
504        Ok(check.manifest)
505    } else {
506        Err(check.errors)
507    }
508}
509
510/// Validation state: the manifest built so far and the problems found.
511struct Check<'a> {
512    file: &'a str,
513    source: &'a str,
514    files: &'a Files<'a>,
515    errors: Vec<ContentError>,
516    /// Sound, music and pool ids seen (one namespace).
517    cue_ids: BTreeSet<String>,
518    manifest: AudioManifest,
519}
520
521impl Check<'_> {
522    /// Records a problem with entry `id`, at the line that names it.
523    fn err(&mut self, id: &str, message: String) {
524        let e = ContentError::new(self.file, message);
525        self.errors
526            .push(match line_of(self.source, &format!("id: \"{id}\"")) {
527                Some(l) => e.at(l, None),
528                None => e,
529            });
530    }
531
532    fn new_cue(&mut self, id: &str) {
533        if id.is_empty() {
534            self.err(id, "a cue id is empty".into());
535        }
536        if !self.cue_ids.insert(id.to_owned()) {
537            self.err(id, format!("duplicate cue id \"{id}\""));
538        }
539    }
540
541    fn volume(&mut self, id: &str, volume: u8) {
542        if volume > MAX_VOLUME {
543            self.err(
544                id,
545                format!("\"{id}\": volume {volume} is over {MAX_VOLUME} (percent)"),
546            );
547        }
548    }
549
550    fn credit_ref(&mut self, id: &str, credit: &CreditRef) {
551        if matches!(credit, CreditRef::Credits(ids) if ids.is_empty()) {
552            self.err(id, format!("\"{id}\" has an empty credit list"));
553        }
554        for c in credit.ids() {
555            if !self.manifest.credits.contains_key(c) {
556                self.err(id, format!("\"{id}\" names unknown credit \"{c}\""));
557            }
558        }
559    }
560
561    fn credit(&mut self, c: RawCredit) {
562        let id = c.id;
563        let entry = Credit {
564            title: c.title,
565            author: c.author,
566            source: c.source,
567            license: c.license,
568            tags: c.tags,
569            note: c.note,
570        };
571        if self.manifest.credits.contains_key(&id) {
572            self.err(&id, format!("duplicate credit id \"{id}\""));
573        }
574        for problem in credit_problems(&entry) {
575            self.err(&id, format!("credit \"{id}\": {problem}"));
576        }
577        self.manifest.credits.insert(id, entry);
578    }
579
580    fn sound(&mut self, s: RawSound) {
581        let id = s.id;
582        self.new_cue(&id);
583        if s.files.is_empty() {
584            self.err(&id, format!("sound \"{id}\" has no files"));
585        }
586        for f in &s.files {
587            let path = (self.files.sound_shown)(f);
588            if !has_extension(f, &["ogg", "wav"]) {
589                self.err(&id, format!("sound \"{id}\": \"{f}\" must be .ogg or .wav"));
590            } else if !(self.files.sound_left_out)(f) {
591                match (self.files.sound)(f) {
592                    None => self.err(&id, format!("sound \"{id}\": file {path} not found")),
593                    Some(bytes) => {
594                        if let Some(why) = format_problem(f, &bytes) {
595                            self.err(&id, format!("sound \"{id}\": {path}: {why}"));
596                        }
597                    }
598                }
599            }
600        }
601        self.volume(&id, s.volume);
602        self.credit_ref(&id, &s.credit);
603        let entry = SoundCue {
604            files: s.files,
605            volume: s.volume,
606            credit: s.credit,
607            looped: s.looped,
608        };
609        self.manifest.sounds.insert(id, entry);
610    }
611
612    fn music(&mut self, m: RawMusic) {
613        let id = m.id;
614        self.new_cue(&id);
615        let f = &m.file;
616        if !has_extension(f, &["ogg"]) {
617            self.err(&id, format!("music \"{id}\": \"{f}\" must be .ogg"));
618        } else if let Some(read) = self.files.music {
619            let dir = self.files.music_dir;
620            match read(f) {
621                None => self.err(&id, format!("music \"{id}\": file {dir}/{f} not found")),
622                Some(bytes) => {
623                    let problem = format_problem(f, &bytes)
624                        .or_else(|| length_problem(m.length_ms, ogg_length_ms(&bytes)));
625                    if let Some(why) = problem {
626                        self.err(&id, format!("music \"{id}\": {dir}/{f}: {why}"));
627                    }
628                }
629            }
630        }
631        if m.length_ms == 0 {
632            let why = "length_ms is 0; it is the track's length in milliseconds";
633            self.err(&id, format!("music \"{id}\": {why}"));
634        }
635        self.volume(&id, m.volume);
636        self.credit_ref(&id, &m.credit);
637        let entry = MusicCue {
638            file: m.file,
639            volume: m.volume,
640            looped: m.looped,
641            length_ms: m.length_ms,
642            credit: m.credit,
643        };
644        self.manifest.music.insert(id, entry);
645    }
646
647    fn pool(&mut self, RawPool { id, music }: RawPool) {
648        self.new_cue(&id);
649        if music.is_empty() {
650            self.err(&id, format!("pool \"{id}\" is empty"));
651        }
652        for m in &music {
653            if self.manifest.sounds.contains_key(m) {
654                let why = format!("pool \"{id}\" names \"{m}\", a sound; pools hold music cues");
655                self.err(&id, why);
656            } else if !self.manifest.music.contains_key(m) {
657                self.err(
658                    &id,
659                    format!("pool \"{id}\" names unknown music cue \"{m}\""),
660                );
661            }
662        }
663        self.manifest.pools.insert(id, music);
664    }
665}
666
667/// What a track whose length can't be read is told.
668const NO_LENGTH: &str = "its length can't be read (no last Ogg page)";
669
670/// Why a music cue's `length_ms` doesn't fit its file, which plays for
671/// `actual` milliseconds ([`ogg_length_ms`]): more than
672/// [`LENGTH_TOLERANCE_MS`] off, or the file's length can't be read. The
673/// message gives the value to write.
674fn length_problem(length_ms: u32, actual: Option<u32>) -> Option<String> {
675    match actual {
676        None => Some(NO_LENGTH.into()),
677        Some(ms) if ms.abs_diff(length_ms) > LENGTH_TOLERANCE_MS => Some(format!(
678            "it plays for {ms} ms, but length_ms is {length_ms}; write length_ms: {ms}"
679        )),
680        Some(_) => None,
681    }
682}
683
684/// What's wrong with a credit: an unknown license, or (for third-party
685/// work) a missing title, author or source link.
686fn credit_problems(c: &Credit) -> Vec<String> {
687    let mut problems = Vec::new();
688    if !LICENSES.contains(&c.license.as_str()) {
689        problems.push(format!(
690            "license \"{}\" is not allowed (ADR-0027: {})",
691            c.license,
692            LICENSES.join(", ")
693        ));
694    }
695    if c.license != OWN {
696        for (field, value) in [("title", &c.title), ("author", &c.author)] {
697            if value.trim().is_empty() {
698                problems.push(format!("{field} is missing"));
699            }
700        }
701        if !c.source.starts_with("https://") && !c.source.starts_with("http://") {
702            problems.push("source must be a web link (http:// or https://)".into());
703        }
704    }
705    problems
706}
707
708fn has_extension(file: &str, allowed: &[&str]) -> bool {
709    file.rsplit_once('.')
710        .is_some_and(|(stem, ext)| !stem.is_empty() && allowed.contains(&ext))
711}
712
713#[cfg(test)]
714mod tests {
715    use alloc::borrow::Cow;
716
717    use super::*;
718
719    #[test]
720    fn without_extension_takes_the_last_names_extension_alone() {
721        assert_eq!(without_extension("sfx/click.wav"), "sfx/click");
722        assert_eq!(without_extension("click.a.ogg"), "click.a");
723        assert_eq!(without_extension("v1.2/click"), "v1.2/click");
724        assert_eq!(without_extension("deep/v1.2/x.ogg"), "deep/v1.2/x");
725        assert_eq!(without_extension("click"), "click");
726    }
727
728    const EMPTY: &str = "(music_fade_ms: 500, sounds: [], music: [], pools: [], credits: [])";
729
730    const FULL: &str = r#"(
731    music_fade_ms: 250,
732    sounds: [
733        (id: "ui_tick", files: ["sfx/ui_tick.wav"], volume: 100, credit: Own),
734        (id: "footfall", files: ["sfx/step_1.ogg", "sfx/step_2.ogg"], volume: 60, credit: Credit("steps")),
735    ],
736    music: [
737        (id: "title", file: "title.ogg", volume: 80, length_ms: 2000, credit: Credit("sunrise")),
738        (id: "sting", file: "sting.ogg", volume: 100, looped: false, length_ms: 2000, credit: Own),
739    ],
740    pools: [
741        (id: "skirmish", music: ["title", "sting"]),
742    ],
743    credits: [
744        (id: "sunrise", title: "New Sunrise", author: "nene",
745         source: "https://opengameart.org/content/new-sunrise", license: "CC0-1.0",
746         tags: ["music", "title"], note: "V1 file"),
747        (id: "steps", title: "Footsteps", author: "someone",
748         source: "https://freesound.org/s/1/", license: "CC-BY-4.0"),
749        (id: "ours", license: "Own"),
750    ],
751)"#;
752
753    /// The start of a WAV file: a `LIST` chunk (odd-sized, so padded), then
754    /// `fmt `.
755    fn wav(channels: u16, rate: u32, encoding: u16) -> Vec<u8> {
756        let mut w = b"RIFF\0\0\0\0WAVELIST\x05\0\0\0abcde\0fmt \x10\0\0\0".to_vec();
757        w.extend_from_slice(&encoding.to_le_bytes());
758        w.extend_from_slice(&channels.to_le_bytes());
759        w.extend_from_slice(&rate.to_le_bytes());
760        w.extend_from_slice(&[0; 8]);
761        w
762    }
763
764    /// The first page of an OGG Vorbis file (two lacing values).
765    fn ogg(channels: u8, rate: u32) -> Vec<u8> {
766        let mut o = b"OggS".to_vec();
767        o.extend_from_slice(&[0; 22]);
768        o.extend_from_slice(&[2, 30, 0]);
769        o.extend_from_slice(b"\x01vorbis\0\0\0\0");
770        o.push(channels);
771        o.extend_from_slice(&rate.to_le_bytes());
772        o
773    }
774
775    /// An Ogg page after the first: `granule` in its header and `body` as
776    /// its one segment.
777    fn ogg_page(granule: u64, body: &[u8]) -> Vec<u8> {
778        let mut p = b"OggS\0\x04".to_vec();
779        p.extend_from_slice(&granule.to_le_bytes());
780        p.extend_from_slice(&[0; 12]);
781        p.extend_from_slice(&[1, u8::try_from(body.len()).unwrap_or(u8::MAX)]);
782        p.extend_from_slice(body);
783        p
784    }
785
786    /// A whole OGG Vorbis file's framing: the first page of [`ogg`],
787    /// filled out to the 30 bytes it declares, then a last page that puts
788    /// the stream at `samples`.
789    fn ogg_file(rate: u32, samples: u64) -> Vec<u8> {
790        let mut o = ogg(2, rate);
791        o.resize(29 + 30, 0);
792        o.extend_from_slice(&ogg_page(samples, &[0; 40]));
793        o
794    }
795
796    /// A good file for `name`'s extension. An OGG plays for 2 s, the
797    /// `length_ms` of [`FULL`]'s tracks.
798    fn good(name: &str) -> Vec<u8> {
799        if has_extension(name, &["wav"]) {
800            wav(2, SAMPLE_RATE, 1)
801        } else {
802            ogg_file(SAMPLE_RATE, u64::from(SAMPLE_RATE) * 2)
803        }
804    }
805
806    /// How the tests read files: sounds are shown under
807    /// `base/audio/`, music under `music/`.
808    fn reading<'a>(
809        sound: &'a dyn Fn(&str) -> Bytes,
810        music: Option<&'a dyn Fn(&str) -> Bytes>,
811    ) -> Files<'a> {
812        Files {
813            sound,
814            sound_shown: &|f: &str| format!("base/audio/{f}"),
815            sound_left_out: &|_: &str| false,
816            music_dir: "music",
817            music,
818        }
819    }
820
821    fn all_files() -> Files<'static> {
822        reading(
823            &|p: &str| Some(Cow::Owned(good(p))),
824            Some(&|f: &str| Some(Cow::Owned(good(f)))),
825        )
826    }
827
828    fn errors(source: &str) -> Vec<String> {
829        errors_with(source, &all_files())
830    }
831
832    fn errors_with(source: &str, files: &Files<'_>) -> Vec<String> {
833        match from_source("a.ron", source, files) {
834            Ok(_) => vec![],
835            Err(e) => e.iter().map(ToString::to_string).collect(),
836        }
837    }
838
839    #[test]
840    fn the_empty_manifest_is_valid() {
841        let m = from_source("a.ron", EMPTY, &all_files());
842        assert_eq!(
843            m,
844            Ok(AudioManifest {
845                music_fade_ms: 500,
846                ..AudioManifest::default()
847            })
848        );
849    }
850
851    /// A file without the fade-in and the overlap reads as before: the
852    /// old track fades out, then the new one starts at full gain.
853    #[test]
854    fn the_default_switch_is_the_fade_out_unless_the_file_says_more() {
855        let m = from_source("a.ron", EMPTY, &all_files()).unwrap_or_default();
856        assert_eq!(m.music_switch(), MusicSwitch::after_fade(500));
857        let source = EMPTY.replace(
858            "music_fade_ms: 500,",
859            "music_fade_ms: 800, music_fade_in_ms: 300, music_overlap: true,",
860        );
861        let m = from_source("a.ron", &source, &all_files()).unwrap_or_default();
862        assert_eq!((m.music_fade_in_ms, m.music_overlap), (300, true));
863        assert_eq!(m.music_switch(), MusicSwitch::new(800, 300, true));
864    }
865
866    #[test]
867    fn the_switch_constructors_set_its_three_fields() {
868        let s = MusicSwitch::new(1, 2, true);
869        assert_eq!((s.out_ms, s.in_ms, s.overlap), (1, 2, true));
870        assert_eq!(
871            MusicSwitch::after_fade(500),
872            MusicSwitch::new(500, 0, false)
873        );
874        assert_eq!(
875            MusicSwitch::crossfade(750),
876            MusicSwitch::new(750, 750, true)
877        );
878    }
879
880    #[test]
881    fn a_full_manifest_loads() {
882        let m = from_source("a.ron", FULL, &all_files()).unwrap_or_default();
883        assert_eq!(m.music_fade_ms, 250);
884        assert_eq!(
885            m.sounds
886                .get("footfall")
887                .map(|s| (&s.files, s.volume, &s.credit)),
888            Some((
889                &vec!["sfx/step_1.ogg".to_owned(), "sfx/step_2.ogg".to_owned()],
890                60,
891                &CreditRef::Credit("steps".into())
892            ))
893        );
894        assert_eq!(
895            m.sounds.get("ui_tick").map(|s| (&s.credit, s.looped)),
896            Some((&CreditRef::Own, false)),
897            "a sound plays once unless it says it loops"
898        );
899        let title = m.music.get("title");
900        assert_eq!(
901            title.map(|t| (t.file.as_str(), t.volume)),
902            Some(("title.ogg", 80))
903        );
904        assert_eq!(
905            title.map(|t| t.looped),
906            Some(true),
907            "music loops by default"
908        );
909        assert_eq!(m.music.get("sting").map(|t| t.looped), Some(false));
910        assert_eq!(title.map(|t| t.length_ms), Some(2000));
911        assert_eq!(
912            m.pools.get("skirmish"),
913            Some(&vec!["title".to_owned(), "sting".to_owned()])
914        );
915        let sunrise = m.credits.get("sunrise").cloned().unwrap_or_default();
916        assert_eq!(
917            (sunrise.title.as_str(), sunrise.author.as_str()),
918            ("New Sunrise", "nene")
919        );
920        assert_eq!(sunrise.license, "CC0-1.0");
921        assert_eq!(sunrise.tags, ["music", "title"]);
922        assert_eq!(sunrise.note, "V1 file");
923        assert_eq!(m.credits.get("ours").map(|c| c.title.as_str()), Some(""));
924    }
925
926    #[test]
927    fn a_sound_may_loop() {
928        let source = r#"(music_fade_ms: 0, music: [], pools: [], credits: [], sounds: [
929            (id: "rain", files: ["sfx/ui_tick.wav"], volume: 40, credit: Own, looped: true),
930        ])"#;
931        let m = from_source("a.ron", source, &all_files()).unwrap_or_default();
932        assert_eq!(m.sounds.get("rain").map(|s| s.looped), Some(true));
933    }
934
935    #[test]
936    fn duplicate_ids_are_refused() {
937        let dup = FULL.replace(r#"(id: "sting""#, r#"(id: "ui_tick""#);
938        assert_eq!(
939            errors(&dup.replace(r#""title", "sting""#, r#""title""#)),
940            ["a.ron:4: duplicate cue id \"ui_tick\""]
941        );
942        let pool = FULL.replace(r#"(id: "skirmish""#, r#"(id: "title""#);
943        assert_eq!(errors(&pool), ["a.ron:8: duplicate cue id \"title\""]);
944        let credit = FULL.replace(r#"(id: "ours""#, r#"(id: "steps""#);
945        assert_eq!(errors(&credit), ["a.ron:18: duplicate credit id \"steps\""]);
946        let empty = FULL.replace(r#"(id: "sting""#, r#"(id: """#);
947        assert_eq!(
948            errors(&empty.replace(r#""title", "sting""#, r#""title""#)),
949            ["a.ron:9: a cue id is empty"]
950        );
951    }
952
953    #[test]
954    fn missing_files_are_refused() {
955        let no_step_2 = |p: &str| (p != "sfx/step_2.ogg").then(|| Cow::Owned(good(p)));
956        let no_title = |f: &str| (f != "title.ogg").then(|| Cow::Owned(good(f)));
957        let files = reading(&no_step_2, Some(&no_title));
958        assert_eq!(
959            errors_with(FULL, &files),
960            [
961                "a.ron:5: sound \"footfall\": file base/audio/sfx/step_2.ogg not found",
962                "a.ron:8: music \"title\": file music/title.ogg not found",
963            ]
964        );
965        // The game itself can't see the music folder and skips that check.
966        let files = reading(&|p: &str| Some(Cow::Owned(good(p))), None);
967        assert_eq!(errors_with(FULL, &files), Vec::<String>::new());
968    }
969
970    #[test]
971    fn a_sound_file_the_build_leaves_out_is_not_checked() {
972        let no_step_2 = |p: &str| (p != "sfx/step_2.ogg").then(|| Cow::Owned(good(p)));
973        let files = Files {
974            sound_left_out: &|p: &str| p == "sfx/step_2.ogg",
975            ..reading(&no_step_2, None)
976        };
977        assert_eq!(errors_with(FULL, &files), Vec::<String>::new());
978        let m = from_source("a.ron", FULL, &files).unwrap_or_default();
979        assert!(
980            m.sounds["footfall"]
981                .files
982                .contains(&"sfx/step_2.ogg".to_owned())
983        );
984        // Its name is still checked.
985        let wrong = FULL.replace("sfx/step_2.ogg", "sfx/step_2.mp3");
986        let files = Files {
987            sound_left_out: &|p: &str| p == "sfx/step_2.mp3",
988            ..reading(&no_step_2, None)
989        };
990        assert_eq!(
991            errors_with(&wrong, &files),
992            ["a.ron:5: sound \"footfall\": \"sfx/step_2.mp3\" must be .ogg or .wav"]
993        );
994    }
995
996    #[test]
997    fn unplayable_files_are_refused() {
998        let swapped = |name: &str| {
999            let bytes = if has_extension(name, &["wav"]) {
1000                ogg(2, SAMPLE_RATE)
1001            } else {
1002                wav(2, SAMPLE_RATE, 1)
1003            };
1004            Some(Cow::Owned(bytes))
1005        };
1006        let files = reading(&swapped, Some(&swapped));
1007        assert_eq!(
1008            errors_with(FULL, &files),
1009            [
1010                "a.ron:4: sound \"ui_tick\": base/audio/sfx/ui_tick.wav: not a PCM WAV file",
1011                "a.ron:5: sound \"footfall\": base/audio/sfx/step_1.ogg: not an OGG Vorbis file",
1012                "a.ron:5: sound \"footfall\": base/audio/sfx/step_2.ogg: not an OGG Vorbis file",
1013                "a.ron:8: music \"title\": music/title.ogg: not an OGG Vorbis file",
1014                "a.ron:9: music \"sting\": music/sting.ogg: not an OGG Vorbis file",
1015            ]
1016        );
1017    }
1018
1019    #[test]
1020    fn file_headers_must_be_44_1_khz_mono_or_stereo() {
1021        let ok = |name, bytes: Vec<u8>| format_problem(name, &bytes);
1022        assert_eq!(ok("a.wav", wav(1, 44_100, 1)), None);
1023        assert_eq!(ok("a.wav", wav(2, 44_100, 3)), None, "float WAV");
1024        assert_eq!(ok("a.ogg", ogg(1, 44_100)), None);
1025        assert_eq!(ok("a.ogg", ogg(2, 44_100)), None);
1026        assert_eq!(
1027            ok("a.wav", wav(2, 48_000, 1)).as_deref(),
1028            Some("48000 Hz; must be 44100 Hz")
1029        );
1030        assert_eq!(
1031            ok("a.ogg", ogg(2, 22_050)).as_deref(),
1032            Some("22050 Hz; must be 44100 Hz")
1033        );
1034        assert_eq!(
1035            ok("a.ogg", ogg(6, 44_100)).as_deref(),
1036            Some("6 channels; must be mono or stereo")
1037        );
1038        assert_eq!(
1039            ok("a.wav", wav(0, 44_100, 1)).as_deref(),
1040            Some("0 channels; must be mono or stereo")
1041        );
1042        let not_wav = Some("not a PCM WAV file");
1043        assert_eq!(ok("a.wav", wav(2, 44_100, 2)).as_deref(), not_wav, "ADPCM");
1044        assert_eq!(
1045            ok("a.wav", b"RIFF\0\0\0\0WAVE".to_vec()).as_deref(),
1046            not_wav
1047        );
1048        assert_eq!(
1049            ok("a.wav", b"RIFX\0\0\0\0WAVE".to_vec()).as_deref(),
1050            not_wav
1051        );
1052        assert_eq!(
1053            ok("a.wav", b"RIFF\0\0\0\0AVI ".to_vec()).as_deref(),
1054            not_wav
1055        );
1056        // A good WAV with one of its two magic words wrong.
1057        let mut rifx = wav(2, 44_100, 1);
1058        rifx[3] = b'X';
1059        assert_eq!(ok("a.wav", rifx).as_deref(), not_wav);
1060        let mut avi = wav(2, 44_100, 1);
1061        avi[8..12].copy_from_slice(b"AVI ");
1062        assert_eq!(ok("a.wav", avi).as_deref(), not_wav);
1063        let mut huge = wav(2, 44_100, 1);
1064        huge[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
1065        assert_eq!(ok("a.wav", huge).as_deref(), not_wav);
1066        assert_eq!(ok("a.wav", Vec::new()).as_deref(), not_wav);
1067        let not_ogg = Some("not an OGG Vorbis file");
1068        let mut opus = ogg(2, 44_100);
1069        opus[30..36].copy_from_slice(b"opus!!");
1070        assert_eq!(ok("a.ogg", opus).as_deref(), not_ogg);
1071        assert_eq!(ok("a.ogg", b"OggS".to_vec()).as_deref(), not_ogg);
1072        assert_eq!(ok("a.ogg", b"ID3\x03".to_vec()).as_deref(), not_ogg);
1073        let short = ogg(2, 44_100)[..40].to_vec();
1074        assert_eq!(ok("a.ogg", short).as_deref(), not_ogg);
1075    }
1076
1077    #[test]
1078    fn an_ogg_length_is_its_last_granule_over_its_sample_rate() {
1079        // The title track: 5,898,057 samples at 44.1 kHz.
1080        assert_eq!(ogg_length_ms(&ogg_file(44_100, 5_898_057)), Some(133_743));
1081        assert_eq!(ogg_length_ms(&ogg_file(44_100, 44_100)), Some(1000));
1082        assert_eq!(ogg_length_ms(&ogg_file(22_050, 44_100)), Some(2000));
1083        assert_eq!(ogg_length_ms(&ogg_file(44_100, 0)), Some(0));
1084        // To the nearest millisecond, halves up.
1085        assert_eq!(ogg_length_ms(&ogg_file(2000, 2)), Some(1));
1086        assert_eq!(ogg_length_ms(&ogg_file(2000, 3)), Some(2));
1087        assert_eq!(ogg_length_ms(&ogg_file(3000, 4)), Some(1));
1088        assert_eq!(ogg_length_ms(&ogg_file(3000, 5)), Some(2));
1089        // The page that counts is the last, whatever came before.
1090        let mut two = ogg_file(44_100, 44_100);
1091        two.extend_from_slice(&ogg_page(88_200, b"tail"));
1092        assert_eq!(ogg_length_ms(&two), Some(2000));
1093        // A page with an empty segment table ends the file too.
1094        let mut bare = ogg_file(44_100, 44_100);
1095        let mut page = ogg_page(132_300, &[]);
1096        page.truncate(27);
1097        page[26] = 0;
1098        bare.extend_from_slice(&page);
1099        assert_eq!(ogg_length_ms(&bare), Some(3000));
1100    }
1101
1102    #[test]
1103    fn oggs_inside_the_audio_data_is_not_a_page() {
1104        // The letters, then bytes that would make a 27-byte "page" far
1105        // short of the end of the file.
1106        let mut body = b"OggS".to_vec();
1107        body.resize(60, 0);
1108        let mut o = ogg_file(44_100, 1);
1109        o.extend_from_slice(&ogg_page(44_100, &body));
1110        assert_eq!(ogg_length_ms(&o), Some(1000));
1111        // And at the very end, where there is no room for a header.
1112        let mut o = ogg_file(44_100, 1);
1113        o.extend_from_slice(&ogg_page(88_200, b"....OggS"));
1114        assert_eq!(ogg_length_ms(&o), Some(2000));
1115    }
1116
1117    #[test]
1118    fn a_file_without_a_readable_end_has_no_length() {
1119        let whole = ogg_file(44_100, 44_100);
1120        assert_eq!(ogg_length_ms(&whole), Some(1000));
1121        // Cut short, or with something after the last page.
1122        assert_eq!(ogg_length_ms(&whole[..whole.len() - 1]), None);
1123        let mut junk = whole.clone();
1124        junk.push(0);
1125        assert_eq!(ogg_length_ms(&junk), None);
1126        // No packet ends on the last page: it carries no position.
1127        assert_eq!(ogg_length_ms(&ogg_file(44_100, u64::MAX)), None);
1128        // A position too big to be a length, and a rate of 0.
1129        assert_eq!(ogg_length_ms(&ogg_file(44_100, u64::MAX - 1)), None);
1130        assert_eq!(ogg_length_ms(&ogg_file(1, u64::from(u32::MAX))), None);
1131        assert_eq!(ogg_length_ms(&ogg_file(0, 44_100)), None);
1132        // Not OGG Vorbis at all.
1133        assert_eq!(ogg_length_ms(&wav(2, 44_100, 1)), None);
1134        assert_eq!(ogg_length_ms(&ogg_page(44_100, &[0; 40])), None);
1135        assert_eq!(ogg_length_ms(&[]), None);
1136        // The header-only first page declares more than it holds.
1137        assert_eq!(ogg_length_ms(&ogg(2, 44_100)), None);
1138    }
1139
1140    /// [`FULL`] with the `sting` track's `length_ms` changed.
1141    fn with_sting_length(length_ms: u32) -> String {
1142        FULL.replace(
1143            "length_ms: 2000, credit: Own",
1144            &format!("length_ms: {length_ms}, credit: Own"),
1145        )
1146    }
1147
1148    #[test]
1149    fn a_music_length_must_match_its_file() {
1150        // `good` tracks play for 2000 ms; 20 ms either way is allowed.
1151        for near in [1980, 2020] {
1152            let errors = errors(&with_sting_length(near));
1153            assert_eq!(errors, Vec::<String>::new(), "{near}");
1154        }
1155        for wrong in [1979, 2021] {
1156            assert_eq!(
1157                errors(&with_sting_length(wrong)),
1158                [format!(
1159                    "a.ron:9: music \"sting\": music/sting.ogg: it plays for 2000 ms, \
1160                     but length_ms is {wrong}; write length_ms: 2000"
1161                )]
1162            );
1163        }
1164        // A file whose end can't be read.
1165        let headers = reading(
1166            &|p: &str| Some(Cow::Owned(good(p))),
1167            Some(&|_: &str| Some(Cow::Owned(ogg(2, SAMPLE_RATE)))),
1168        );
1169        assert_eq!(
1170            errors_with(FULL, &headers),
1171            [
1172                "a.ron:8: music \"title\": music/title.ogg: its length can't be read (no last Ogg page)",
1173                "a.ron:9: music \"sting\": music/sting.ogg: its length can't be read (no last Ogg page)",
1174            ]
1175        );
1176    }
1177
1178    #[test]
1179    fn a_music_length_of_zero_is_refused() {
1180        // Where the music folder can't be seen, any length but 0 passes.
1181        let unseen = reading(&|p: &str| Some(Cow::Owned(good(p))), None);
1182        assert_eq!(
1183            errors_with(&with_sting_length(1), &unseen),
1184            Vec::<String>::new()
1185        );
1186        assert_eq!(
1187            errors_with(&with_sting_length(0), &unseen),
1188            ["a.ron:9: music \"sting\": length_ms is 0; it is the track's length in milliseconds"]
1189        );
1190        // The field is required.
1191        let missing = FULL.replace("looped: false, length_ms: 2000, ", "looped: false, ");
1192        assert_eq!(errors(&missing).len(), 1);
1193    }
1194
1195    #[test]
1196    fn file_formats_are_checked() {
1197        let mp3 = FULL.replace("title.ogg", "title.mp3");
1198        assert_eq!(
1199            errors(&mp3),
1200            ["a.ron:8: music \"title\": \"title.mp3\" must be .ogg"]
1201        );
1202        let wav_music = FULL.replace("title.ogg", "title.wav");
1203        assert_eq!(errors(&wav_music).len(), 1);
1204        let flac = FULL.replace("ui_tick.wav", "ui_tick.flac");
1205        assert_eq!(
1206            errors(&flac),
1207            ["a.ron:4: sound \"ui_tick\": \"sfx/ui_tick.flac\" must be .ogg or .wav"]
1208        );
1209        assert_eq!(errors(&FULL.replace("sfx/ui_tick.wav", ".wav")).len(), 1);
1210        assert_eq!(errors(&FULL.replace("sfx/ui_tick.wav", "wav")).len(), 1);
1211        let none = FULL.replace(r#"["sfx/ui_tick.wav"]"#, "[]");
1212        assert_eq!(errors(&none), ["a.ron:4: sound \"ui_tick\" has no files"]);
1213    }
1214
1215    #[test]
1216    fn unknown_licenses_are_refused() {
1217        let nc = FULL.replace("\"CC-BY-4.0\"", "\"CC-BY-NC-4.0\"");
1218        assert_eq!(
1219            errors(&nc),
1220            [
1221                "a.ron:18: credit \"steps\": license \"CC-BY-NC-4.0\" is not allowed \
1222                 (ADR-0027: CC0-1.0, CC-BY-3.0, CC-BY-4.0, Own)"
1223            ]
1224        );
1225        // CC BY 3.0 is allowed too (Nick, ticket 0214; game ADR-0027).
1226        let by3 = FULL.replace("\"CC-BY-4.0\"", "\"CC-BY-3.0\"");
1227        assert_eq!(errors(&by3), Vec::<String>::new());
1228    }
1229
1230    #[test]
1231    fn third_party_credits_need_title_author_and_link() {
1232        let bare = FULL.replace(
1233            r#"title: "Footsteps", author: "someone","#,
1234            r#"title: " ", author: "","#,
1235        );
1236        let bare = bare.replace("https://freesound.org/s/1/", "freesound 1");
1237        assert_eq!(
1238            errors(&bare),
1239            [
1240                "a.ron:18: credit \"steps\": title is missing",
1241                "a.ron:18: credit \"steps\": author is missing",
1242                "a.ron:18: credit \"steps\": source must be a web link (http:// or https://)",
1243            ]
1244        );
1245        let http = FULL.replace("https://freesound", "http://freesound");
1246        assert_eq!(errors(&http), Vec::<String>::new());
1247    }
1248
1249    #[test]
1250    fn credits_must_exist() {
1251        let unknown = FULL.replace(r#"Credit("steps")"#, r#"Credit("stepz")"#);
1252        assert_eq!(
1253            errors(&unknown),
1254            ["a.ron:5: \"footfall\" names unknown credit \"stepz\""]
1255        );
1256        let unknown = FULL.replace(r#"Credit("sunrise")"#, r#"Credit("dawn")"#);
1257        assert_eq!(
1258            errors(&unknown),
1259            ["a.ron:8: \"title\" names unknown credit \"dawn\""]
1260        );
1261    }
1262
1263    #[test]
1264    fn a_cue_may_name_several_credits() {
1265        let several = FULL.replace(r#"Credit("steps")"#, r#"Credits(["steps", "sunrise"])"#);
1266        let m = from_source("a.ron", &several, &all_files()).unwrap_or_default();
1267        let ids = m.sounds.get("footfall").map(|s| s.credit.ids().to_vec());
1268        assert_eq!(ids, Some(vec!["steps".to_owned(), "sunrise".to_owned()]));
1269        let got = CreditRef::Own.ids();
1270        assert!(got.is_empty(), "{got:?}");
1271        let unknown = FULL.replace(r#"Credit("steps")"#, r#"Credits(["steps", "stepz"])"#);
1272        assert_eq!(
1273            errors(&unknown),
1274            ["a.ron:5: \"footfall\" names unknown credit \"stepz\""]
1275        );
1276        let empty = FULL.replace(r#"Credit("steps")"#, "Credits([])");
1277        assert_eq!(
1278            errors(&empty),
1279            ["a.ron:5: \"footfall\" has an empty credit list"]
1280        );
1281    }
1282
1283    #[test]
1284    fn pools_hold_only_music_cues() {
1285        let sound = FULL.replace(r#""title", "sting""#, r#""title", "ui_tick""#);
1286        assert_eq!(
1287            errors(&sound),
1288            ["a.ron:12: pool \"skirmish\" names \"ui_tick\", a sound; pools hold music cues"]
1289        );
1290        let unknown = FULL.replace(r#""title", "sting""#, r#""title", "nope""#);
1291        assert_eq!(
1292            errors(&unknown),
1293            ["a.ron:12: pool \"skirmish\" names unknown music cue \"nope\""]
1294        );
1295        let empty = FULL.replace(r#""title", "sting""#, "");
1296        assert_eq!(errors(&empty), ["a.ron:12: pool \"skirmish\" is empty"]);
1297    }
1298
1299    #[test]
1300    fn volumes_are_percentages() {
1301        assert_eq!(
1302            errors(&FULL.replace("volume: 60", "volume: 100")),
1303            Vec::<String>::new()
1304        );
1305        assert_eq!(
1306            errors(&FULL.replace("volume: 60", "volume: 101")),
1307            ["a.ron:5: \"footfall\": volume 101 is over 100 (percent)"]
1308        );
1309        assert_eq!(
1310            errors(&FULL.replace("volume: 80", "volume: 101")),
1311            ["a.ron:8: \"title\": volume 101 is over 100 (percent)"]
1312        );
1313    }
1314
1315    #[test]
1316    fn syntax_and_unknown_fields_are_refused() {
1317        assert_eq!(errors("(").len(), 1);
1318        assert_eq!(
1319            errors(&FULL.replace("volume: 80", "volume: 80, loud: true")).len(),
1320            1
1321        );
1322        assert_eq!(errors(&EMPTY.replace("pools: [], ", "")).len(), 1);
1323    }
1324
1325    #[test]
1326    fn the_constructors_give_the_literals() {
1327        fn none(_: &str) -> Bytes {
1328            None
1329        }
1330        fn shown(name: &str) -> String {
1331            format!("base/audio/{name}")
1332        }
1333        fn kept(_: &str) -> bool {
1334            false
1335        }
1336        /// Files as games build theirs, in a `const` item.
1337        const FILES: Files<'static> = Files::new(&none, &shown, &kept, "music");
1338        const WITH_MUSIC: Files<'static> = FILES.with_music(Some(&none));
1339        assert_eq!(
1340            SoundCue::new(alloc::vec!["sfx/a.wav".into()], 60, CreditRef::Own),
1341            SoundCue {
1342                files: alloc::vec!["sfx/a.wav".into()],
1343                volume: 60,
1344                credit: CreditRef::Own,
1345                looped: false,
1346            }
1347        );
1348        assert!(
1349            SoundCue::new(Vec::new(), 60, CreditRef::Own)
1350                .with_looped(true)
1351                .looped
1352        );
1353        assert_eq!((FILES.music_dir, FILES.music.is_none()), ("music", true));
1354        assert_eq!((FILES.sound_shown)("x.wav"), "base/audio/x.wav");
1355        assert!(!(FILES.sound_left_out)("x.wav"));
1356        assert!((FILES.sound)("x.wav").is_none());
1357        assert!(
1358            WITH_MUSIC
1359                .music
1360                .is_some_and(|music| music("a.ogg").is_none())
1361        );
1362    }
1363}