Credits: credit every third-party work whose own text states holder and licence; test discovers all licence-like files

Adds Liberation Sans (OFL), UniRx, KDTree, webgl-noise simplex noise and ambientCG to the list. CreditsTests now
matches LICEN[CS]E, COPYING, NOTICE, README, SOURCES, OFL files under Assets and requires each to be credited or
listed as reviewed-not-credited with a reason; credit notices are checked against what their files state. The
attribution heading is checked rather than skipped, the test states independent expectations, and the split Hurricane
line is joined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nibu 2026-11-12 12:43:50 +00:00
commit 16cb82d096
3 changed files with 188 additions and 25 deletions

View file

@ -2,8 +2,7 @@ Noun Project
Tornado by Bart Laubsch
Alien Abduction by Bart Laubsch
Hurricane by icon 54 from
Smashicons Space by Ben Davis
Hurricane by icon 54 from Smashicons Space by Ben Davis
Space 2 - Solid by Ben Davis
Filter by Karthik Aathis
Search by il Capitano

View file

@ -19,6 +19,13 @@ public static class Credits
public const string TwemojiFile = "Assets/Fonts/Emoji/LICENSE.txt";
public const string PlaydeadFile = "Assets/Plugins/Playdead/LICENSE.txt";
public const string NounProjectFile = "Assets/Resources/Sprites/Attribution.txt";
public const string LiberationFile = "Assets/TextMesh Pro/Fonts/LiberationSans - OFL.txt";
public const string UniRxFile = "Assets/Plugins/UniRx/ReadMe.txt";
public const string AmbientCgFile = "Assets/Materials/PBR/SOURCES.md";
public const string KdTreeFile = "Assets/Scripts/ServerShared/NIH/KDTree/KDTree.cs";
public const string SimplexNoiseFile = "Assets/Plugins/GPU Noise/SimplexNoise2D.cginc";
// The first line of the attribution file; the icon credits are the lines after it.
public const string NounProjectHeading = "Noun Project";
// Where the player build loads the Noun Project credit lines from (Resources path, no extension).
public const string NounProjectResource = "Sprites/Attribution";
@ -45,6 +52,44 @@ public static class Credits
LicenceFile = PlaydeadFile
},
new Credit
{
Name = "Liberation Sans",
Licence = "SIL Open Font License 1.1",
Notice = "Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine. " +
"Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.",
LicenceFile = LiberationFile
},
new Credit
{
Name = "UniRx",
Licence = "MIT",
Notice = "Created by Yoshifumi Kawai (neuecc)",
LicenceFile = UniRxFile
},
new Credit
{
Name = "KDTree",
Licence = "MIT",
Notice = "Copyright (c) 2018 Vili Volčini / viliwonka",
LicenceFile = KdTreeFile
},
new Credit
{
Name = "webgl-noise simplex noise",
Licence = "MIT",
Notice = "Copyright (C) 2011 Ashima Arts. Author: Ian McEwan, Ashima Arts " +
"(https://github.com/ashima/webgl-noise). Translated to Unity shaders and modified by Keijiro Takahashi " +
"(https://github.com/keijiro/NoiseShader).",
LicenceFile = SimplexNoiseFile
},
new Credit
{
Name = "ambientCG PBR materials",
Licence = "CC0 1.0 Universal",
Notice = "Texture sets from ambientCG (https://ambientcg.com), public domain",
LicenceFile = AmbientCgFile
},
new Credit
{
Name = "Noun Project icons",
Licence = "Noun Project attribution licence",
@ -56,9 +101,16 @@ public static class Credits
return credits;
}
// The icon credit lines of the attribution file: everything after its "Noun Project" heading.
public static IEnumerable<string> NounProjectLines(string attribution) =>
(attribution ?? "").Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).Skip(1);
// The icon credit lines of the attribution file: everything after its "Noun Project" heading, one line per icon (the
// file's "Other" sub-heading and its lines follow in the same list). A file that does not open with the heading is
// refused rather than having its first line dropped.
public static IEnumerable<string> NounProjectLines(string attribution)
{
var lines = (attribution ?? "").Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToArray();
if (lines.Length < 2 || lines[0] != NounProjectHeading)
throw new InvalidOperationException($"{NounProjectFile} must open with the heading '{NounProjectHeading}' followed by icon credits");
return lines.Skip(1);
}
// Throws on an entry a player-facing screen could not honestly show.
public static void Validate(IReadOnlyList<Credit> credits)

View file

@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace Aetheria.Shared.Tests
@ -19,41 +20,151 @@ namespace Aetheria.Shared.Tests
private static System.Collections.Generic.IReadOnlyList<Credit> Load() => Credits.All(Read(Credits.NounProjectFile));
[Fact]
public void EveryLicenceFileUnderAssetsIsCredited()
// A file under Assets that looks like a licence, attribution, notice, readme or sources list. Each one is either
// credited (the credit names it as its LicenceFile) or reviewed below with the reason it is not.
private static readonly Regex LicenceLike = new Regex(
@"^(LICEN[CS]E|COPYING|NOTICE|README|SOURCES|_SOURCES|Attribution)|OFL", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly string[] SkippedDirectories = { "bin", "obj", "Library" };
// Discovered files whose own text names no holder and no licence, so there is nothing true to credit. A new
// licence-like file is not here until someone has read it and written the reason.
private static readonly System.Collections.Generic.Dictionary<string, string> ReviewedNotCredited = new System.Collections.Generic.Dictionary<string, string>
{
["Assets/Scripts/ServerShared/NIH/LinqFaster/readme.txt"] = "usage guide for LinqFaster; names no holder and no licence",
["Assets/Plugins/LowPoly_AsteroidsPack/README.pdf"] = "how to customise the asteroid pack; names no holder and no licence",
["Assets/Plugins/Celestial Body/Textures/_Sources.txt"] = "URLs of the source textures only; states no licence",
};
// Bundled works with no licence-like file at all, found by reading Assets. Their licences are not stated in the
// repo, so they are not credited; each path must still exist, so this list cannot go stale unnoticed.
private static readonly string[] UnlicensedWorks =
{
"Assets/Fonts/Ubuntu",
"Assets/Fonts/Retro",
"Assets/Plugins/Ink",
"Assets/Plugins/GPUTrail",
"Assets/Plugins/com.unity.uiextensions",
"Assets/Shaders/StableFluids",
};
// What each credited file itself states, written here independently of the credit text: these strings must
// appear in the file and in the credit's notice.
private static readonly (string file, string[] stated)[] Holders =
{
(Credits.TwemojiFile, new[] { "Jason Sofonia & Justine De Caires", "Twitter", "CC BY 4.0" }),
(Credits.PlaydeadFile, new[] { "Playdead" }),
(Credits.LiberationFile, new[] { "Google Corporation", "Red Hat, Inc." }),
(Credits.UniRxFile, new[] { "Yoshifumi Kawai" }),
(Credits.KdTreeFile, new[] { "Vili Vol\u010dini / viliwonka" }),
(Credits.SimplexNoiseFile, new[] { "Ashima Arts", "Ian McEwan", "Keijiro Takahashi" }),
(Credits.AmbientCgFile, new[] { "ambientCG" }),
};
private static string[] LicenceLikeFiles()
{
var root = Root();
var found = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories)
return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories)
.Where(path => !path.EndsWith(".meta"))
.Where(path => { var name = Path.GetFileName(path); return name.StartsWith("LICENSE", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Attribution", StringComparison.OrdinalIgnoreCase); })
.Select(path => Path.GetRelativePath(root, path).Replace('\\', '/'))
.Where(path => !path.Split('/').Any(segment => SkippedDirectories.Contains(segment)))
.Where(path => LicenceLike.IsMatch(Path.GetFileName(path)))
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
Assert.Contains(Credits.TwemojiFile, found);
var credited = Load().Select(credit => credit.LicenceFile).ToHashSet();
Assert.Empty(found.Where(path => !credited.Contains(path)));
Assert.Empty(credited.Where(path => !File.Exists(Path.Combine(root, path))));
}
// The ways a licence-like file can be accounted for; shared so the mutations in the report hit the same rule.
private static string[] Unaccounted(string[] found, System.Collections.Generic.IEnumerable<string> credited) =>
found.Where(path => !credited.Contains(path) && !ReviewedNotCredited.ContainsKey(path)).ToArray();
[Fact]
public void TwemojiComesFirstWithTheHoldersItsLicenceNames()
public void EveryLicenceLikeFileUnderAssetsIsCreditedOrReviewed()
{
var root = Root();
var found = LicenceLikeFiles();
Assert.Contains(Credits.TwemojiFile, found);
Assert.Contains(Credits.LiberationFile, found);
var credited = Load().Select(credit => credit.LicenceFile).ToArray();
Assert.Equal(Array.Empty<string>(), Unaccounted(found, credited));
Assert.Equal(Array.Empty<string>(), credited.Where(path => !File.Exists(Path.Combine(root, path))).ToArray());
Assert.Equal(Array.Empty<string>(), ReviewedNotCredited.Keys.Where(path => !found.Contains(path)).ToArray());
Assert.Equal(Array.Empty<string>(), ReviewedNotCredited.Keys.Where(path => credited.Contains(path)).ToArray());
Assert.Equal(Array.Empty<string>(), UnlicensedWorks.Where(path => !Directory.Exists(Path.Combine(root, path))).ToArray());
}
// Every holder the licence file states is in the credit that answers for it; the notices are typed into
// Credits.cs, so this is what keeps them true to the files.
[Fact]
public void EveryCreditShowsTheHoldersItsOwnFileStates()
{
var credits = Load();
Assert.Equal(Holders.Select(h => h.file).Append(Credits.NounProjectFile).OrderBy(f => f, StringComparer.Ordinal),
credits.Select(credit => credit.LicenceFile).OrderBy(f => f, StringComparer.Ordinal));
foreach (var (file, stated) in Holders)
{
var text = Normal(Read(file));
var notice = Normal(credits.Single(credit => credit.LicenceFile == file).Notice);
foreach (var holder in stated)
{
Assert.True(text.Contains(Normal(holder)), $"{file} does not state '{holder}'");
Assert.True(notice.Contains(Normal(holder)), $"the credit for {file} omits '{holder}'");
}
// And every copyright line the file carries, whatever the table above says.
foreach (var line in Read(file).Split('\n'))
{
var match = CopyrightLine.Match(line);
if (!match.Success) continue;
var body = Regex.Replace(match.Groups[1].Value.Replace("[", "").Replace("]", "").Trim(), @"(\s*All rights reserved)?\.?$", "", RegexOptions.IgnoreCase);
Assert.True(notice.Contains(Normal(body)), $"the credit for {file} omits its copyright line '{body}'");
}
}
}
private static readonly Regex CopyrightLine = new Regex(@"copyright\s*(?:\(c\)|\u00a9)?\s*(\[?\d.*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Dash variants and case folded, so the file's en dash and the screen's hyphen compare equal.
private static string Normal(string text) => text.Replace('\u2013', '-').Replace('\u2014', '-').Replace("\r", "").ToLowerInvariant();
[Fact]
public void TwemojiComesFirst()
{
var twemoji = Load()[0];
Assert.Equal("Twemoji", twemoji.Name);
Assert.Contains("CC BY 4.0", twemoji.Licence);
Assert.Contains("Jason Sofonia & Justine De Caires", Read(Credits.TwemojiFile));
Assert.Contains("Jason Sofonia & Justine De Caires", twemoji.Notice);
Assert.Contains("Twitter", twemoji.Notice);
}
// The expectations are stated here, not derived from the parser: 81 icon credits between the heading and the
// "Other" sub-heading's two artists, named at the ends and at the line that was once split across two.
[Fact]
public void EveryIconCreditInTheAttributionFileIsShown()
{
var lines = Credits.NounProjectLines(Read(Credits.NounProjectFile)).ToArray();
Assert.True(lines.Length > 50);
var text = Read(Credits.NounProjectFile);
Assert.StartsWith(Credits.NounProjectHeading, text.TrimStart('\uFEFF'));
var lines = Credits.NounProjectLines(text).ToArray();
Assert.Equal(82, lines.Length);
Assert.Equal("Tornado by Bart Laubsch", lines[0]);
Assert.Equal("Alien Abduction by Bart Laubsch", lines[1]);
Assert.Equal("Hurricane by icon 54 from Smashicons Space by Ben Davis", lines[2]);
Assert.Equal("Space 2 - Solid by Ben Davis", lines[3]);
Assert.Contains("Other", lines);
Assert.Equal("Missile War Weapon by Freepik", lines[^1]);
// One credit per line: every line but the sub-heading names an artist and does not stop mid-sentence.
foreach (var line in lines.Where(line => line != "Other"))
{
Assert.Contains(" by ", line, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotMatch(@"\s(from|by)$", line);
}
var notice = Load().Single(credit => credit.LicenceFile == Credits.NounProjectFile).Notice.Split('\n');
Assert.Equal(lines, notice);
}
[Fact]
public void AnAttributionFileWithoutItsHeadingIsRefused()
{
Assert.Throws<InvalidOperationException>(() => Credits.NounProjectLines("Tornado by Bart Laubsch\nAlien Abduction by Bart Laubsch").ToArray());
Assert.Throws<InvalidOperationException>(() => Credits.NounProjectLines("Noun Project\n\n").ToArray());
}
[Fact]
public void EntriesNameTheirLicenceAndHolder()
{
@ -63,8 +174,6 @@ namespace Aetheria.Shared.Tests
Assert.False(string.IsNullOrWhiteSpace(credit.Licence));
Assert.False(string.IsNullOrWhiteSpace(credit.Notice));
}
Assert.Contains("Playdead", Read(Credits.PlaydeadFile));
Assert.Contains("Playdead", Load().Single(credit => credit.LicenceFile == Credits.PlaydeadFile).Notice);
}
[Fact]
@ -73,17 +182,20 @@ namespace Aetheria.Shared.Tests
Assert.Throws<InvalidOperationException>(() => Credits.Validate(new[] { new Credit { Name = "x", Licence = "", Notice = "n", LicenceFile = "f" } }));
}
// The Unity menu is not compilable headlessly, so this reads its source: the main menu must offer Credits and
// the Credits screen must render the Shared list. It proves the wiring exists, not that the screen draws.
// The Unity menu is not compilable headlessly, so this reads its source with comments removed (a commented-out
// button or call must not satisfy it): the main menu must offer Credits, and ShowCredits must build the Shared
// list. It proves the wiring exists, not that the screen draws.
[Fact]
public void MainMenuOffersACreditsPathThatRendersTheList()
{
var menu = Read("Assets/Scripts/UI/MainMenu.cs");
var menu = Regex.Replace(Read("Assets/Scripts/UI/MainMenu.cs"), @"/\*.*?\*/|//[^\n]*", "", RegexOptions.Singleline);
var main = menu.Substring(menu.IndexOf("private void ShowMain()", StringComparison.Ordinal));
main = main.Substring(0, main.IndexOf("private void ShowScenarios()", StringComparison.Ordinal));
main = main.Substring(0, main.IndexOf("private void ShowCredits()", StringComparison.Ordinal));
Assert.Contains("AddButton(\"Credits\"", main);
Assert.Contains("ShowCredits()", main);
Assert.Contains("Credits.All(", menu);
var credits = menu.Substring(menu.IndexOf("private void ShowCredits()", StringComparison.Ordinal));
credits = credits.Substring(0, credits.IndexOf("private void ShowScenarios()", StringComparison.Ordinal));
Assert.Contains("Credits.All(", credits);
}
}
}