Skip to main content

shuyi_assets/
credits.rs

1//! The credits: every third-party work a game uses, for its credits
2//! screen. Music and sounds are credited in the audio manifest
3//! ([`crate::audio`]); everything else (fonts, bought art, vendored
4//! software) in a credits file. [`merge`] makes the two one list, so
5//! nothing is typed twice.
6//!
7//! A bought work names the files of the game's private folder it covers
8//! (the bought art, kept out of the public repository). It is shown only
9//! in a build that has files there, and [`uncredited`] finds the bought
10//! files no credit covers (game ADR-0051). As a format (ADR-0007):
11//! [`CreditsRon`].
12use crate::prelude::*;
13
14use alloc::collections::BTreeSet;
15
16use serde::{Deserialize, Serialize};
17
18use crate::audio::{self, AudioManifest};
19use crate::error::{ContentError, line_of};
20use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
21use crate::ron::parse_ron;
22
23impl AssetKind for CreditsFile {
24    const NAME: &'static str = "credits";
25}
26
27/// A credits file (`credits`, `.ron`), checked as [`from_source`] says,
28/// its errors naming the game's other files as [`Places`] does.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CreditsRon {
31    audio_manifest: String,
32    private_root: String,
33}
34
35impl CreditsRon {
36    /// The credits of a game whose audio manifest errors call
37    /// `audio_manifest` and whose private folder they call `private_root`.
38    pub fn new(audio_manifest: impl Into<String>, private_root: impl Into<String>) -> Self {
39        Self {
40            audio_manifest: audio_manifest.into(),
41            private_root: private_root.into(),
42        }
43    }
44}
45
46impl Default for CreditsRon {
47    /// Errors call them "the audio manifest" and `private`.
48    fn default() -> Self {
49        Self::new("the audio manifest", "private")
50    }
51}
52
53impl Format for CreditsRon {
54    type Asset = CreditsFile;
55
56    fn id(&self) -> &'static str {
57        "credits"
58    }
59
60    fn extensions(&self) -> &'static [&'static str] {
61        &["ron"]
62    }
63
64    fn max_bytes(&self) -> usize {
65        TEXT_MAX_BYTES
66    }
67
68    fn decode(
69        &self,
70        file: &AssetPath,
71        bytes: &[u8],
72        _cx: &DecodeCx<'_>,
73    ) -> Result<CreditsFile, Vec<ContentError>> {
74        let places = Places {
75            audio_manifest: &self.audio_manifest,
76            private_root: &self.private_root,
77        };
78        from_source(file.shown(), text(file, bytes)?, &places)
79    }
80}
81
82/// Licenses an entry of the credits file may have, unless it is a bought
83/// work ([`FileCredit::private`]): game ADR-0013's code and font lists.
84pub const LICENSES: [&str; 15] = [
85    "MIT",
86    "MIT-0",
87    "Apache-2.0",
88    "Apache-2.0 WITH LLVM-exception",
89    "BSD-2-Clause",
90    "BSD-3-Clause",
91    "0BSD",
92    "ISC",
93    "Zlib",
94    "BSL-1.0",
95    "Unicode-3.0",
96    "Unicode-DFS-2016",
97    "CC0-1.0",
98    "Unlicense",
99    "OFL-1.1",
100];
101
102/// The kinds of work the credits screen groups by, in the order it shows
103/// them.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
105pub enum CreditGroup {
106    /// Music tracks (from the audio manifest's music cues).
107    Music,
108    /// Sound effects (from the audio manifest's sound cues).
109    SoundEffects,
110    /// Pictures: portraits, sprites, tiles.
111    Art,
112    /// Fonts.
113    Fonts,
114    /// Vendored code and data tables.
115    Software,
116}
117
118impl CreditGroup {
119    /// Every group, in display order.
120    pub const ALL: [Self; 5] = [
121        Self::Music,
122        Self::SoundEffects,
123        Self::Art,
124        Self::Fonts,
125        Self::Software,
126    ];
127
128    /// Whether the group's credits come from the audio manifest, so the
129    /// credits file may not use it.
130    pub fn is_audio(self) -> bool {
131        matches!(self, Self::Music | Self::SoundEffects)
132    }
133}
134
135/// One entry of the credits file.
136#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
137#[serde(deny_unknown_fields)]
138pub struct FileCredit {
139    /// Stable id, unique in the file.
140    pub id: String,
141    /// What kind of work it is; not an audio group.
142    pub group: CreditGroup,
143    /// The work's title.
144    pub title: String,
145    /// Its author, as they want to be credited.
146    pub author: String,
147    /// The page it came from: the link in its `THIRD_PARTY_ASSETS.md` row.
148    pub source: String,
149    /// One of [`LICENSES`]; for a bought work, `Custom (<seller>)`.
150    pub license: String,
151    /// Kept off the credits screen: plumbing the player never sees.
152    #[serde(default)]
153    pub hidden: bool,
154    /// For a bought work: the files of the private folder it covers, each
155    /// a path there or the start of one (`units/`). Empty for any other
156    /// work.
157    #[serde(default)]
158    pub private: Vec<String>,
159}
160
161impl FileCredit {
162    /// Whether the credit covers the bought file at `path` (relative to
163    /// the private folder).
164    pub fn covers(&self, path: &str) -> bool {
165        self.private.iter().any(|p| path.starts_with(p.as_str()))
166    }
167}
168
169/// The note in the root of the private folder: ours, so it needs no
170/// credit.
171const PRIVATE_README: &str = "README.md";
172
173/// Whether `license` is how a bought work's is written: `Custom (<seller>)`.
174fn is_custom(license: &str) -> bool {
175    let seller = license
176        .strip_prefix("Custom (")
177        .and_then(|rest| rest.strip_suffix(')'));
178    seller.is_some_and(|s| !s.trim().is_empty())
179}
180
181/// The bought files among `paths` (relative to the private folder) that
182/// no credit of `file` covers. Each needs one before it ships
183/// (game ADR-0051).
184pub fn uncredited<'a>(file: &CreditsFile, paths: &[&'a str]) -> Vec<&'a str> {
185    let covered = |path: &str| file.credits.iter().any(|c| c.covers(path));
186    let needs_one = paths.iter().filter(|p| **p != PRIVATE_README);
187    needs_one.copied().filter(|p| !covered(p)).collect()
188}
189
190#[derive(Deserialize)]
191#[serde(deny_unknown_fields)]
192struct RawFile {
193    credits: Vec<FileCredit>,
194}
195
196/// The validated credits file, in file order, hidden entries included.
197#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
198pub struct CreditsFile {
199    /// The entries.
200    pub credits: Vec<FileCredit>,
201}
202
203/// One work as the credits screen shows it.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct CreditEntry {
206    /// The heading it goes under.
207    pub group: CreditGroup,
208    /// The work's title.
209    pub title: String,
210    /// Its author.
211    pub author: String,
212    /// Its license (an SPDX id, e.g. `CC-BY-4.0`).
213    pub license: String,
214    /// The page it came from.
215    pub source: String,
216}
217
218/// Every credit the screen shows: sorted by group ([`CreditGroup::ALL`]),
219/// audio works by title within their group, the file's in file order.
220#[derive(Debug, Clone, Default, PartialEq, Eq)]
221pub struct Credits {
222    /// The entries, in display order.
223    pub entries: Vec<CreditEntry>,
224}
225
226impl Credits {
227    /// The entries under `group`, in display order.
228    pub fn in_group(&self, group: CreditGroup) -> impl Iterator<Item = &CreditEntry> {
229        self.entries.iter().filter(move |e| e.group == group)
230    }
231}
232
233/// Where the game keeps what a credits file's errors point to.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct Places<'a> {
236    /// The audio manifest, as errors show it (`base/audio/audio.ron`):
237    /// where music and sound credits go.
238    pub audio_manifest: &'a str,
239    /// The private folder of bought files, as errors show it
240    /// (`private`).
241    pub private_root: &'a str,
242}
243
244/// Parses and validates credits `source`, attributing errors to `file`
245/// and naming the game's other files by `places`. Reports every problem
246/// found.
247pub fn from_source(
248    file: &str,
249    source: &str,
250    places: &Places<'_>,
251) -> Result<CreditsFile, Vec<ContentError>> {
252    let raw: RawFile = parse_ron(file, source).map_err(|e| vec![e])?;
253    let mut errors = Vec::new();
254    let mut ids = BTreeSet::new();
255    for c in &raw.credits {
256        let mut err = |message: String| {
257            let e = ContentError::new(file, format!("credit \"{}\": {message}", c.id));
258            errors.push(match line_of(source, &format!("id: \"{}\"", c.id)) {
259                Some(l) => e.at(l, None),
260                None => e,
261            });
262        };
263        if c.id.is_empty() {
264            err("the id is empty".into());
265        }
266        if !ids.insert(c.id.as_str()) {
267            err("the id is used twice".into());
268        }
269        if c.group.is_audio() {
270            err(format!(
271                "{:?} credits go in {}",
272                c.group, places.audio_manifest
273            ));
274        }
275        for (field, value) in [("title", &c.title), ("author", &c.author)] {
276            if value.trim().is_empty() {
277                err(format!("{field} is missing"));
278            }
279        }
280        if !c.source.starts_with("https://") && !c.source.starts_with("http://") {
281            err("source must be a web link (http:// or https://)".into());
282        }
283        if !c.private.is_empty() {
284            if !is_custom(&c.license) {
285                err(format!(
286                    "license \"{}\": a bought work's is written \"Custom (<seller>)\"",
287                    c.license
288                ));
289            }
290        } else if !LICENSES.contains(&c.license.as_str()) {
291            err(format!(
292                "license \"{}\" is not allowed (ADR-0013: {})",
293                c.license,
294                LICENSES.join(", ")
295            ));
296        }
297        for path in &c.private {
298            if path.is_empty() || path.starts_with('/') {
299                err(format!(
300                    "private path \"{path}\" must be a path inside {}/",
301                    places.private_root
302                ));
303            }
304        }
305    }
306    if errors.is_empty() {
307        Ok(CreditsFile {
308            credits: raw.credits,
309        })
310    } else {
311        Err(errors)
312    }
313}
314
315/// The credits the screen shows: the third-party works `audio`'s music
316/// cues name, then those its sound cues name (each once, by title; a work
317/// used for both counts as music), then `file`'s entries that aren't
318/// hidden. Our own work ([`audio::OWN`]) isn't listed, and a bought work
319/// only when one of `bought` (the build's files from the private folder)
320/// is its: a build without the art doesn't use it.
321pub fn merge(file: &CreditsFile, audio: &AudioManifest, bought: &[&str]) -> Credits {
322    let music = audio.music.values().map(|m| &m.credit);
323    let sounds = audio.sounds.values().map(|s| &s.credit);
324    let mut seen = BTreeSet::new();
325    let mut entries = Vec::new();
326    for (group, refs) in [
327        (CreditGroup::Music, music.collect::<Vec<_>>()),
328        (CreditGroup::SoundEffects, sounds.collect()),
329    ] {
330        let from = entries.len();
331        for id in refs.into_iter().flat_map(audio::CreditRef::ids) {
332            let Some(c) = audio.credits.get(id) else {
333                continue;
334            };
335            if c.license != audio::OWN && seen.insert(id) {
336                entries.push(CreditEntry {
337                    group,
338                    title: c.title.clone(),
339                    author: c.author.clone(),
340                    license: c.license.clone(),
341                    source: c.source.clone(),
342                });
343            }
344        }
345        entries[from..].sort_by_key(|e| e.title.to_lowercase());
346    }
347    let in_build = |c: &FileCredit| c.private.is_empty() || bought.iter().any(|p| c.covers(p));
348    let shown = file.credits.iter().filter(|c| !c.hidden && in_build(c));
349    entries.extend(shown.map(|c| CreditEntry {
350        group: c.group,
351        title: c.title.clone(),
352        author: c.author.clone(),
353        license: c.license.clone(),
354        source: c.source.clone(),
355    }));
356    // Stable: keeps the orders above within each group.
357    entries.sort_by_key(|e| e.group);
358    Credits { entries }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::audio::{Credit, CreditRef, MusicCue, SoundCue};
365
366    const FILE: &str = r#"(credits: [
367    (id: "font", group: Fonts, title: "A Font", author: "Ann",
368     source: "https://example.org/font", license: "OFL-1.1"),
369    (id: "loader", group: Software, title: "Loader", author: "Bob",
370     source: "http://example.org/loader", license: "MIT", hidden: true),
371    (id: "table", group: Software, title: "Table", author: "Cy",
372     source: "https://example.org/table", license: "Zlib"),
373])"#;
374
375    const PLACES: Places<'static> = Places {
376        audio_manifest: "base/audio/audio.ron",
377        private_root: "private",
378    };
379
380    fn from_source(file: &str, source: &str) -> Result<CreditsFile, Vec<ContentError>> {
381        super::from_source(file, source, &PLACES)
382    }
383
384    fn errors(source: &str) -> Vec<String> {
385        from_source("c.ron", source)
386            .err()
387            .unwrap_or_default()
388            .iter()
389            .map(ToString::to_string)
390            .collect()
391    }
392
393    fn credit(title: &str, license: &str) -> Credit {
394        Credit {
395            title: title.into(),
396            author: format!("{title}'s author"),
397            source: format!("https://example.org/{title}"),
398            license: license.into(),
399            ..Credit::default()
400        }
401    }
402
403    fn music(credit: CreditRef) -> MusicCue {
404        MusicCue {
405            file: "a.ogg".into(),
406            volume: 100,
407            looped: true,
408            length_ms: 1000,
409            credit,
410        }
411    }
412
413    fn sound(credit: CreditRef) -> SoundCue {
414        SoundCue {
415            files: vec!["a.ogg".into()],
416            volume: 100,
417            credit,
418            looped: false,
419        }
420    }
421
422    /// Two tracks, three sounds (one ours, one sharing a track's work).
423    fn manifest() -> AudioManifest {
424        let one = |id: &str| CreditRef::Credit(id.into());
425        let mut m = AudioManifest::default();
426        for (id, c) in [
427            ("zeta", credit("Zeta", "CC0-1.0")),
428            ("alpha", credit("alpha", "CC-BY-4.0")),
429            ("step", credit("Step", "CC-BY-3.0")),
430            ("clang", credit("Clang", "CC0-1.0")),
431            ("ours", credit("", audio::OWN)),
432            ("unused", credit("Unused", "CC0-1.0")),
433        ] {
434            m.credits.insert(id.into(), c);
435        }
436        m.music.insert("a".into(), music(one("zeta")));
437        m.music.insert("b".into(), music(one("alpha")));
438        m.music.insert("c".into(), music(one("zeta")));
439        m.music.insert("d".into(), music(CreditRef::Own));
440        m.music.insert("e".into(), music(one("ours")));
441        m.sounds.insert(
442            "s".into(),
443            sound(CreditRef::Credits(vec!["step".into(), "clang".into()])),
444        );
445        m.sounds.insert("t".into(), sound(one("zeta")));
446        m.sounds.insert("u".into(), sound(CreditRef::Own));
447        m.sounds.insert("v".into(), sound(one("missing")));
448        m
449    }
450
451    fn shown(credits: &Credits) -> Vec<(CreditGroup, &str)> {
452        credits
453            .entries
454            .iter()
455            .map(|e| (e.group, e.title.as_str()))
456            .collect()
457    }
458
459    #[test]
460    fn a_good_file_loads_in_file_order() {
461        let file = from_source("c.ron", FILE).unwrap_or_default();
462        let ids: Vec<&str> = file.credits.iter().map(|c| c.id.as_str()).collect();
463        assert_eq!(ids, ["font", "loader", "table"]);
464        let hidden: Vec<bool> = file.credits.iter().map(|c| c.hidden).collect();
465        assert_eq!(hidden, [false, true, false]);
466        assert_eq!(file.credits[0].group, CreditGroup::Fonts);
467        assert_eq!(file.credits[0].license, "OFL-1.1");
468        assert_eq!(
469            from_source("c.ron", "(credits: [])"),
470            Ok(CreditsFile::default())
471        );
472    }
473
474    #[test]
475    fn bad_entries_are_reported_with_their_line() {
476        let dup = FILE.replace(r#"id: "table""#, r#"id: "font""#);
477        assert_eq!(
478            errors(&dup),
479            ["c.ron:2: credit \"font\": the id is used twice"]
480        );
481        let empty = FILE.replace(r#"id: "table""#, r#"id: """#);
482        assert_eq!(errors(&empty), ["c.ron:6: credit \"\": the id is empty"]);
483        let bare = FILE
484            .replace(
485                r#"title: "Table", author: "Cy""#,
486                r#"title: " ", author: """#,
487            )
488            .replace("https://example.org/table", "example.org/table");
489        assert_eq!(
490            errors(&bare),
491            [
492                "c.ron:6: credit \"table\": title is missing",
493                "c.ron:6: credit \"table\": author is missing",
494                "c.ron:6: credit \"table\": source must be a web link (http:// or https://)",
495            ]
496        );
497        let gpl = FILE.replace("\"Zlib\"", "\"GPL-3.0\"");
498        assert_eq!(
499            errors(&gpl),
500            [format!(
501                "c.ron:6: credit \"table\": license \"GPL-3.0\" is not allowed (ADR-0013: {})",
502                LICENSES.join(", ")
503            )]
504        );
505    }
506
507    #[test]
508    fn audio_groups_belong_to_the_audio_manifest() {
509        assert_eq!(
510            errors(&FILE.replace("group: Fonts", "group: Music")),
511            ["c.ron:2: credit \"font\": Music credits go in base/audio/audio.ron"]
512        );
513        assert_eq!(
514            errors(&FILE.replace("group: Fonts", "group: SoundEffects")),
515            ["c.ron:2: credit \"font\": SoundEffects credits go in base/audio/audio.ron"]
516        );
517        let audio: Vec<bool> = CreditGroup::ALL.iter().map(|g| g.is_audio()).collect();
518        assert_eq!(audio, [true, true, false, false, false]);
519    }
520
521    #[test]
522    fn syntax_and_unknown_fields_are_refused() {
523        assert_eq!(errors("(credits: [").len(), 1);
524        assert_eq!(errors(&FILE.replace("hidden: true", "hide: true")).len(), 1);
525        assert_eq!(
526            errors(&FILE.replace("group: Fonts", "group: Pics")).len(),
527            1
528        );
529    }
530
531    #[test]
532    fn merging_groups_sorts_and_drops_what_is_not_shown() {
533        use CreditGroup::{Fonts, Music, Software, SoundEffects};
534        let file = from_source("c.ron", FILE).unwrap_or_default();
535        let credits = merge(&file, &manifest(), &[]);
536        // Music by title whatever its case, each work once; a work used by
537        // a track and a sound is music; ours, the unused credit, the
538        // unknown id and the hidden entry aren't shown.
539        assert_eq!(
540            shown(&credits),
541            [
542                (Music, "alpha"),
543                (Music, "Zeta"),
544                (SoundEffects, "Clang"),
545                (SoundEffects, "Step"),
546                (Fonts, "A Font"),
547                (Software, "Table"),
548            ]
549        );
550        let first = &credits.entries[0];
551        assert_eq!(first.author, "alpha's author");
552        assert_eq!(first.license, "CC-BY-4.0");
553        assert_eq!(first.source, "https://example.org/alpha");
554        let font = credits.in_group(Fonts).collect::<Vec<_>>();
555        assert_eq!(font.len(), 1);
556        assert_eq!(
557            (font[0].author.as_str(), font[0].license.as_str()),
558            ("Ann", "OFL-1.1")
559        );
560        assert_eq!(font[0].source, "https://example.org/font");
561        assert_eq!(credits.in_group(Music).count(), 2);
562    }
563
564    #[test]
565    fn file_entries_keep_their_order_and_sort_under_their_group() {
566        use CreditGroup::{Fonts, Software};
567        // Software before Fonts in the file: Fonts still shows first, and
568        // two entries of one group stay in file order.
569        let source = r#"(credits: [
570            (id: "z", group: Software, title: "Zed", author: "a", source: "https://z", license: "MIT"),
571            (id: "b", group: Software, title: "Bee", author: "a", source: "https://b", license: "MIT"),
572            (id: "f", group: Fonts, title: "Font", author: "a", source: "https://f", license: "MIT"),
573        ])"#;
574        let file = from_source("c.ron", source).unwrap_or_default();
575        let credits = merge(&file, &AudioManifest::default(), &[]);
576        assert_eq!(
577            shown(&credits),
578            [(Fonts, "Font"), (Software, "Zed"), (Software, "Bee")]
579        );
580    }
581
582    /// [`FILE`] with a bought pack between the font and the loader.
583    fn with_bought() -> String {
584        let pack = r#"    (id: "pack", group: Art, title: "Pack", author: "Dee",
585     source: "https://example.org/pack", license: "Custom (Dee)",
586     private: ["units/", "tilesets/pack.ron"]),
587    (id: "loader""#;
588        FILE.replace(r#"    (id: "loader""#, pack)
589    }
590
591    #[test]
592    fn a_bought_work_names_its_private_files_and_its_sellers_license() {
593        let file = from_source("c.ron", &with_bought()).unwrap_or_default();
594        assert_eq!(file.credits[1].private, ["units/", "tilesets/pack.ron"]);
595        assert_eq!(file.credits[1].group, CreditGroup::Art);
596        let got = &file.credits[0].private;
597        assert!(got.is_empty(), "{got:?}");
598        // An open licence isn't a bought work's, and a custom one is only
599        // a bought work's.
600        for bad in [
601            "MIT",
602            "Custom",
603            "Custom ()",
604            "Custom ( )",
605            "Custom (Dee",
606            "(Dee)",
607        ] {
608            assert_eq!(
609                errors(&with_bought().replace("Custom (Dee)", bad)),
610                [format!(
611                    "c.ron:4: credit \"pack\": license \"{bad}\": a bought work's is written \
612                     \"Custom (<seller>)\""
613                )]
614            );
615        }
616        let custom = FILE.replace("\"Zlib\"", "\"Custom (Cy)\"");
617        assert_eq!(errors(&custom).len(), 1);
618        assert!(errors(&custom)[0].contains("is not allowed (ADR-0013: "));
619        for bad in ["", "/units/"] {
620            assert_eq!(
621                errors(&with_bought().replace("\"units/\"", &format!("\"{bad}\""))),
622                [format!(
623                    "c.ron:4: credit \"pack\": private path \"{bad}\" must be a path inside \
624                     private/"
625                )]
626            );
627        }
628    }
629
630    #[test]
631    fn a_credit_covers_the_files_under_its_private_paths() {
632        let file = from_source("c.ron", &with_bought()).unwrap_or_default();
633        let pack = &file.credits[1];
634        assert!(pack.covers("units/a.png"));
635        assert!(pack.covers("tilesets/pack.ron"));
636        assert!(!pack.covers("tilesets/other.ron"));
637        assert!(!pack.covers("portraits/units/a.png"));
638        assert!(!file.credits[0].covers("units/a.png"));
639        let paths = [
640            "README.md",
641            "portraits/README.md",
642            "portraits/a.png",
643            "tilesets/pack.ron",
644            "units/a.png",
645        ];
646        assert_eq!(
647            uncredited(&file, &paths),
648            ["portraits/README.md", "portraits/a.png"]
649        );
650        assert_eq!(
651            uncredited(&CreditsFile::default(), &paths),
652            paths[1..].to_vec()
653        );
654        let got = uncredited(&file, &[]);
655        assert!(got.is_empty(), "{got:?}");
656    }
657
658    #[test]
659    fn a_bought_work_is_shown_only_in_a_build_that_has_its_files() {
660        use CreditGroup::{Art, Fonts, Software};
661        let file = from_source("c.ron", &with_bought()).unwrap_or_default();
662        let none = AudioManifest::default();
663        let without = [(Fonts, "A Font"), (Software, "Table")];
664        assert_eq!(shown(&merge(&file, &none, &[])), without);
665        assert_eq!(shown(&merge(&file, &none, &["portraits/a.png"])), without);
666        let with = merge(&file, &none, &["portraits/a.png", "units/a.png"]);
667        assert_eq!(
668            shown(&with),
669            [(Art, "Pack"), (Fonts, "A Font"), (Software, "Table")]
670        );
671        assert_eq!(with.entries[0].license, "Custom (Dee)");
672        // Hidden stays hidden, with its files or not.
673        let hidden = with_bought().replace("private: [", "hidden: true, private: [");
674        let file = from_source("c.ron", &hidden).unwrap_or_default();
675        assert_eq!(shown(&merge(&file, &none, &["units/a.png"])), without);
676    }
677}