From b367f80e6d13c9a9dae716d02f9065611c5bb510 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 09:47:53 +0000 Subject: [PATCH 1/7] Credits screen: one attribution list in Shared, reachable from the main menu Twemoji, Playdead and the Noun Project icon credits, held against the licence files under Assets by CreditsTests. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Credits.cs | 73 +++++++++++++++++ Assets/Scripts/ServerShared/Credits.cs.meta | 11 +++ Assets/Scripts/UI/MainMenu.cs | 32 ++++++++ tests/Aetheria.Shared.Tests/CreditsTests.cs | 89 +++++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 Assets/Scripts/ServerShared/Credits.cs create mode 100644 Assets/Scripts/ServerShared/Credits.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/CreditsTests.cs diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs new file mode 100644 index 000000000..3501ed2c9 --- /dev/null +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +// The third-party attributions the shipped game must show the player. One list, owned here: the credits screen only +// renders it, and CreditsTests holds it against the licence files in Assets. Unity-free so the headless build checks it. +public class Credit +{ + public string Name; + public string Licence; + // What the licence requires shown: copyright holders, and any change notice. + public string Notice; + // The licence file in the repo that carries the full text and that this entry answers for. + public string LicenceFile; +} + +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"; + // Where the player build loads the Noun Project credit lines from (Resources path, no extension). + public const string NounProjectResource = "Sprites/Attribution"; + + // The full list. The Noun Project icon credits are the lines of its attribution file, whose text the caller supplies + // (Unity reads it as a Resources TextAsset; tests read it from disk), so the file stays the one copy of those names. + public static IReadOnlyList All(string nounProjectAttribution) + { + var credits = new List + { + new Credit + { + Name = "Twemoji", + Licence = "CC BY 4.0", + Notice = "Copyright (c) 2022-present Jason Sofonia & Justine De Caires; copyright (c) 2014-2021 Twitter. " + + "Graphics licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). " + + "Converted to a colour font; the artwork itself is not edited.", + LicenceFile = TwemojiFile + }, + new Credit + { + Name = "Playdead temporal anti-aliasing", + Licence = "MIT", + Notice = "Copyright (c) 2015 Playdead", + LicenceFile = PlaydeadFile + }, + new Credit + { + Name = "Noun Project icons", + Licence = "Noun Project attribution licence", + Notice = string.Join("\n", NounProjectLines(nounProjectAttribution)), + LicenceFile = NounProjectFile + } + }; + Validate(credits); + return credits; + } + + // The icon credit lines of the attribution file: everything after its "Noun Project" heading. + public static IEnumerable NounProjectLines(string attribution) => + (attribution ?? "").Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).Skip(1); + + // Throws on an entry a player-facing screen could not honestly show. + public static void Validate(IReadOnlyList credits) + { + foreach (var credit in credits) + if (string.IsNullOrWhiteSpace(credit.Name) || string.IsNullOrWhiteSpace(credit.Licence) || + string.IsNullOrWhiteSpace(credit.Notice) || string.IsNullOrWhiteSpace(credit.LicenceFile)) + throw new InvalidOperationException($"Credit '{credit.Name}' is missing its name, licence, notice or licence file"); + var duplicate = credits.GroupBy(credit => credit.LicenceFile).FirstOrDefault(group => group.Count() > 1); + if (duplicate != null) throw new InvalidOperationException($"Licence file {duplicate.Key} is credited twice"); + } +} diff --git a/Assets/Scripts/ServerShared/Credits.cs.meta b/Assets/Scripts/ServerShared/Credits.cs.meta new file mode 100644 index 000000000..e88730bb6 --- /dev/null +++ b/Assets/Scripts/ServerShared/Credits.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3333d3c0203441fbadd64fad27b60e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 9eef387ca..88600e516 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -136,9 +136,41 @@ public class MainMenu : MonoBehaviour ShowSettings(); Fade(true); }); + _nextMenu.panel.AddButton("Credits", + () => + { + ShowCredits(); + Fade(true); + }); _nextMenu.panel.AddButton("Quit", Application.Quit); } + // The third-party attributions the build must show, one button per entry with its notice in the detail pane. The + // list is Credits.All; this only renders it. + private void ShowCredits() + { + var credits = Credits.All(Resources.Load(Credits.NounProjectResource).text); + Next("credits"); + _nextMenu.detail.gameObject.SetActive(true); + _nextMenu.detail.Title.text = _nextMenu.panel.Title.text; + _nextMenu.detail.Title.color = Color.clear; + var selected = 0; + _nextMenu.detail.AddProperty(() => credits[selected].Name); + _nextMenu.detail.AddProperty(() => credits[selected].Licence); + _nextMenu.detail.AddProperty(() => credits[selected].Notice); + for (var i = 0; i < credits.Count; i++) + { + var index = i; + _nextMenu.panel.AddButton(credits[i].Name, () => selected = index); + } + _nextMenu.panel.AddButton("Back", + () => + { + ShowMain(); + Fade(false); + }); + } + // New Game: every way to set up a run, each with its brief. A release build lists only the modes; the other ways // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs new file mode 100644 index 000000000..8f2691cf7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; + +namespace Aetheria.Shared.Tests +{ + // The credits list against the repo's own licence files and the main menu that must reach it. + public class CreditsTests + { + private static string Root() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Assets", "Scripts", "UI", "MainMenu.cs"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } + + private static string Read(string relative) => File.ReadAllText(Path.Combine(Root(), relative)); + + private static System.Collections.Generic.IReadOnlyList Load() => Credits.All(Read(Credits.NounProjectFile)); + + [Fact] + public void EveryLicenceFileUnderAssetsIsCredited() + { + var root = Root(); + var found = 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('\\', '/')) + .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)))); + } + + [Fact] + public void TwemojiComesFirstWithTheHoldersItsLicenceNames() + { + 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); + } + + [Fact] + public void EveryIconCreditInTheAttributionFileIsShown() + { + var lines = Credits.NounProjectLines(Read(Credits.NounProjectFile)).ToArray(); + Assert.True(lines.Length > 50); + var notice = Load().Single(credit => credit.LicenceFile == Credits.NounProjectFile).Notice.Split('\n'); + Assert.Equal(lines, notice); + } + + [Fact] + public void EntriesNameTheirLicenceAndHolder() + { + foreach (var credit in Load()) + { + Assert.False(string.IsNullOrWhiteSpace(credit.Name)); + 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] + public void AnEmptyEntryIsRefused() + { + Assert.Throws(() => 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. + [Fact] + public void MainMenuOffersACreditsPathThatRendersTheList() + { + var menu = Read("Assets/Scripts/UI/MainMenu.cs"); + var main = menu.Substring(menu.IndexOf("private void ShowMain()", StringComparison.Ordinal)); + main = main.Substring(0, main.IndexOf("private void ShowScenarios()", StringComparison.Ordinal)); + Assert.Contains("AddButton(\"Credits\"", main); + Assert.Contains("ShowCredits()", main); + Assert.Contains("Credits.All(", menu); + } + } +} From 16cb82d096dabec3007396b8069bbf4ab9b155e8 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 12:43:50 +0000 Subject: [PATCH 2/7] 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 --- Assets/Resources/Sprites/Attribution.txt | 3 +- Assets/Scripts/ServerShared/Credits.cs | 58 +++++++- tests/Aetheria.Shared.Tests/CreditsTests.cs | 154 +++++++++++++++++--- 3 files changed, 189 insertions(+), 26 deletions(-) diff --git a/Assets/Resources/Sprites/Attribution.txt b/Assets/Resources/Sprites/Attribution.txt index 666742406..8ca89fb1b 100644 --- a/Assets/Resources/Sprites/Attribution.txt +++ b/Assets/Resources/Sprites/Attribution.txt @@ -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 diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs index 3501ed2c9..3bb0af84b 100644 --- a/Assets/Scripts/ServerShared/Credits.cs +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -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 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 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 credits) diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index 8f2691cf7..b346f1fca 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -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 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 ReviewedNotCredited = new System.Collections.Generic.Dictionary + { + ["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 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(), Unaccounted(found, credited)); + Assert.Equal(Array.Empty(), credited.Where(path => !File.Exists(Path.Combine(root, path))).ToArray()); + Assert.Equal(Array.Empty(), ReviewedNotCredited.Keys.Where(path => !found.Contains(path)).ToArray()); + Assert.Equal(Array.Empty(), ReviewedNotCredited.Keys.Where(path => credited.Contains(path)).ToArray()); + Assert.Equal(Array.Empty(), 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(() => Credits.NounProjectLines("Tornado by Bart Laubsch\nAlien Abduction by Bart Laubsch").ToArray()); + Assert.Throws(() => 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(() => 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); } } } From 390b4c7a457bff9bd87e2e4149f0057cea7d8832 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 12:48:52 +0000 Subject: [PATCH 3/7] Credits screen: one row per notice line, refuse a missing attribution file, follow the New Game focus handling ShowCredits renders the selected credit as property rows instead of one multi-line label, refuses with a dialog when the attribution TextAsset is missing or malformed, takes list rows out of automatic navigation, steps with Up/Down on the focused Back button and leaves on Cancel. ScenarioPicker's focus hold moves to FocusHold so both screens share it. The menu wiring test now strips comments before matching. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/UI/MainMenu.cs | 80 +++++++++++++++------ Assets/Scripts/UI/ScenarioPicker.cs | 30 ++++---- tests/Aetheria.Shared.Tests/CreditsTests.cs | 7 +- 3 files changed, 83 insertions(+), 34 deletions(-) diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 88600e516..e7aba3357 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -7,7 +7,9 @@ using System.Threading; using System.Threading.Tasks; using TMPro; using UniRx; +using UniRx.Triggers; using UnityEngine; +using UnityEngine.EventSystems; using UnityEngine.SceneManagement; using UnityEngine.UI; using static CultMath.math; @@ -139,36 +141,74 @@ public class MainMenu : MonoBehaviour _nextMenu.panel.AddButton("Credits", () => { - ShowCredits(); - Fade(true); + if (ShowCredits()) Fade(true); }); _nextMenu.panel.AddButton("Quit", Application.Quit); } - // The third-party attributions the build must show, one button per entry with its notice in the detail pane. The - // list is Credits.All; this only renders it. - private void ShowCredits() + // The third-party attributions the build must show, one button per entry; the selected entry's name, licence and one + // row per notice line fill the detail pane. The list is Credits.All; this only renders it. The screen follows the + // New Game screen: list rows are out of automatic navigation, the focused Back button takes Up and Down to step the + // selection and Cancel to leave, and a click on empty space does not drop that focus. + // False when the list could not be built and a dialog says why; the menu stays where it was. + private bool ShowCredits() { - var credits = Credits.All(Resources.Load(Credits.NounProjectResource).text); + var attribution = Resources.Load(Credits.NounProjectResource); + if (attribution == null) + { + Refuse("Credits are unavailable", $"{Credits.NounProjectResource} is missing from this build."); + return false; + } + IReadOnlyList credits; + try { credits = Credits.All(attribution.text); } + catch (InvalidOperationException error) + { + Refuse("Credits are unavailable", error.Message); + return false; + } Next("credits"); - _nextMenu.detail.gameObject.SetActive(true); - _nextMenu.detail.Title.text = _nextMenu.panel.Title.text; - _nextMenu.detail.Title.color = Color.clear; - var selected = 0; - _nextMenu.detail.AddProperty(() => credits[selected].Name); - _nextMenu.detail.AddProperty(() => credits[selected].Licence); - _nextMenu.detail.AddProperty(() => credits[selected].Notice); + var list = _nextMenu.panel; + var detail = _nextMenu.detail; + detail.gameObject.SetActive(true); + var focus = new FocusHold(); + var selected = -1; + Button back = null; + void Leave() + { + FocusHold.Focus(null); + focus.Release(); + ShowMain(); + Fade(false); + } + void Select(int index) + { + selected = Mathf.Clamp(index, 0, credits.Count - 1); + var credit = credits[selected]; + detail.Clear(); + detail.Title.text = list.Title.text; + detail.Title.color = Color.clear; + detail.AddProperty(() => credit.Name); + detail.AddProperty(() => credit.Licence); + detail.AddSpacer(); + foreach (var line in credit.Notice.Split('\n')) detail.AddProperty(() => line); + FocusHold.Focus(back.gameObject); + } for (var i = 0; i < credits.Count; i++) { var index = i; - _nextMenu.panel.AddButton(credits[i].Name, () => selected = index); + list.AddButton(credits[i].Name, () => Select(index)).Button.navigation = new Navigation { mode = Navigation.Mode.None }; } - _nextMenu.panel.AddButton("Back", - () => - { - ShowMain(); - Fade(false); - }); + back = list.AddButton("Back", Leave).Button; + back.navigation = new Navigation { mode = Navigation.Mode.None }; + back.OnMoveAsObservable().Subscribe(move => + { + if (move.moveDir == MoveDirection.Up) Select(selected - 1); + else if (move.moveDir == MoveDirection.Down) Select(selected + 1); + }); + back.OnCancelAsObservable().Subscribe(_ => Leave()); + focus.Hold(); + Select(0); + return true; } // New Game: every way to set up a run, each with its brief. A release build lists only the modes; the other ways diff --git a/Assets/Scripts/UI/ScenarioPicker.cs b/Assets/Scripts/UI/ScenarioPicker.cs index e0cd93134..f5f3a1ca4 100644 --- a/Assets/Scripts/UI/ScenarioPicker.cs +++ b/Assets/Scripts/UI/ScenarioPicker.cs @@ -30,7 +30,7 @@ public sealed class ScenarioPicker private static readonly Color Accent = new Color(1, .4458f, .05f); private int _selected; private bool _launched; - private bool _restoreDeselect; + private readonly FocusHold _focus = new FocusHold(); private ScenarioPicker(PropertiesPanel list, PropertiesPanel detail, Action launch, Action back) { @@ -115,7 +115,7 @@ public sealed class ScenarioPicker picker._launchButton.OnCancelAsObservable().Subscribe(_ => picker.Back()); picker.StyleScrollbars(); - picker.HoldFocus(); + picker._focus.Hold(); picker.Select(0); // Layout is not live until the frame after Show; edit mode has no frames. if (Application.isPlaying) Observable.NextFrame().Subscribe(_ => picker.FitViewport()); @@ -229,7 +229,7 @@ public sealed class ScenarioPicker _rows[i].colors = colors; _rows[i].targetGraphic.color = i == _selected ? Accent : _rowFills[i]; } - Focus(_launchButton.gameObject); + FocusHold.Focus(_launchButton.gameObject); Reveal((RectTransform) _rows[_selected].transform); } @@ -253,36 +253,42 @@ public sealed class ScenarioPicker { if (_launched) return; _launched = true; - Focus(null); - ReleaseFocus(); + FocusHold.Focus(null); + _focus.Release(); _launch(Selected); } // Leaves the screen; the same path for the Back button and Cancel. private void Back() { - Focus(null); - ReleaseFocus(); + FocusHold.Focus(null); + _focus.Release(); _back(); } +} - // While the picker is up, a click on empty space must not drop focus from Launch, or the arrows and Enter go dead. - // The input module's flag is scene-wide, so the picker takes it for its own lifetime and gives it back on leaving. - private void HoldFocus() +// Keeps keyboard and gamepad focus on one control while a menu screen is up. While held, a click on empty space must +// not drop focus, or the arrows and Enter go dead. The input module's flag is scene-wide, so the screen takes it for its +// own lifetime and gives it back on leaving. The New Game and Credits screens both use it. +public sealed class FocusHold +{ + private bool _restoreDeselect; + + public void Hold() { if (EventSystem.current == null || !(EventSystem.current.currentInputModule is InputSystemUIInputModule module) || !module.deselectOnBackgroundClick) return; module.deselectOnBackgroundClick = false; _restoreDeselect = true; } - private void ReleaseFocus() + public void Release() { if (!_restoreDeselect) return; _restoreDeselect = false; if (EventSystem.current != null && EventSystem.current.currentInputModule is InputSystemUIInputModule module) module.deselectOnBackgroundClick = true; } - private static void Focus(GameObject target) + public static void Focus(GameObject target) { if (EventSystem.current != null) EventSystem.current.SetSelectedGameObject(target); } diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index b346f1fca..ed322bef4 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -190,12 +190,15 @@ namespace Aetheria.Shared.Tests { 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 ShowCredits()", StringComparison.Ordinal)); + main = main.Substring(0, main.IndexOf("private bool ShowCredits()", StringComparison.Ordinal)); Assert.Contains("AddButton(\"Credits\"", main); Assert.Contains("ShowCredits()", main); - var credits = menu.Substring(menu.IndexOf("private void ShowCredits()", StringComparison.Ordinal)); + var credits = menu.Substring(menu.IndexOf("private bool ShowCredits()", StringComparison.Ordinal)); credits = credits.Substring(0, credits.IndexOf("private void ShowScenarios()", StringComparison.Ordinal)); Assert.Contains("Credits.All(", credits); + // A build missing the attribution file refuses with a dialog instead of throwing in the click handler. + Assert.Contains("attribution == null", credits); + Assert.Contains("Refuse(", credits); } } } From 1537bc2c52e72da9615f0512bf78c0f7cdead3f3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 14:37:48 +0000 Subject: [PATCH 4/7] Credits: credit the Ubuntu and Montserrat fonts from their embedded notices; review unreferenced fonts; stop skipping obj/bin/Library under Assets Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Credits.cs | 17 ++++ tests/Aetheria.Shared.Tests/CreditsTests.cs | 106 ++++++++++++++++++-- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs index 3bb0af84b..cfc3028fb 100644 --- a/Assets/Scripts/ServerShared/Credits.cs +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -20,6 +20,9 @@ public static class Credits 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"; + // Fonts state their holder and licence in their own name tables; these are the font files the credit answers for. + public const string UbuntuFontFile = "Assets/Fonts/Ubuntu/Ubuntu-R.ttf"; + public const string MontserratFontFile = "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf"; 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"; @@ -60,6 +63,20 @@ public static class Credits LicenceFile = LiberationFile }, new Credit + { + Name = "Ubuntu and Ubuntu Mono fonts", + Licence = "Ubuntu Font Licence 1.0", + Notice = "Copyright 2011 Canonical Ltd.", + LicenceFile = UbuntuFontFile + }, + new Credit + { + Name = "Montserrat", + Licence = "SIL Open Font License 1.1", + Notice = "Copyright(c) 2014 by Julieta Ulanovsky", + LicenceFile = MontserratFontFile + }, + new Credit { Name = "UniRx", Licence = "MIT", diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index ed322bef4..8d4a4a6b9 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -25,8 +25,6 @@ namespace Aetheria.Shared.Tests 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 ReviewedNotCredited = new System.Collections.Generic.Dictionary @@ -40,8 +38,6 @@ namespace Aetheria.Shared.Tests // 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", @@ -61,13 +57,70 @@ namespace Aetheria.Shared.Tests (Credits.AmbientCgFile, new[] { "ambientCG" }), }; + // Fonts that state holder and licence in their own name tables: the credit's LicenceFile is the first font, and + // every font listed must embed the stated string (read from the file, not from the credit) and be shown by the + // credit. The ids are TrueType name ids: 0 is the copyright notice. + private static readonly (string credit, string[] fonts, string embedded)[] FontCredits = + { + (Credits.UbuntuFontFile, new[] { "Assets/Fonts/Ubuntu/Ubuntu-R.ttf", "Assets/Fonts/Ubuntu/Ubuntu-L.ttf", "Assets/Fonts/Ubuntu/Ubuntu-B.ttf", "Assets/Fonts/Ubuntu/UbuntuMono-R.ttf" }, + "Copyright 2011 Canonical Ltd."), + (Credits.MontserratFontFile, new[] { "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf" }, "Copyright(c) 2014 by Julieta Ulanovsky"), + }; + + // Fonts credited through a licence file in the repo (see Holders), not through their own text. + private static readonly string[] FontsCreditedByLicenceFile = { "Assets/Fonts/Emoji/Twemoji.ttf", "Assets/TextMesh Pro/Fonts/LiberationSans.ttf" }; + + // Fonts under Assets that are not credited, each with the reason; every one must be unreferenced by any prefab, + // scene, material or asset outside Assets/Fonts (checked), so shipping it is not a decision made by accident. + private static readonly System.Collections.Generic.Dictionary ReviewedFonts = new System.Collections.Generic.Dictionary + { + ["Assets/Fonts/Retro/LRT Oksana.ttf"] = "EULA forbids more than personal use; unreferenced; decision owed", + ["Assets/Fonts/Retro/LRT Oksana Bold.ttf"] = "EULA forbids more than personal use; unreferenced; decision owed", + ["Assets/Fonts/Ubuntu/UbuntuMono-B.ttf"] = "unreferenced; same holder and licence as UbuntuMono-R, which the Ubuntu credit covers", + ["Assets/Fonts/ftn_SourceSansPro-Regular.otf"] = "unreferenced; embeds Adobe copyright under the SIL OFL 1.1; credit it if it is ever used", + }; + + private static string[] FontFiles() + { + var root = Root(); + return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => path.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".otf", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + // Every name-table record of a TrueType/OpenType file with the given name id, decoded (platform 3 as UTF-16BE, + // platform 1 as Latin-1). + private static System.Collections.Generic.List NameRecords(string relative, int nameId) + { + var d = File.ReadAllBytes(Path.Combine(Root(), relative)); + int U16(int at) => (d[at] << 8) | d[at + 1]; + long U32(int at) => ((long)d[at] << 24) | ((long)d[at + 1] << 16) | ((long)d[at + 2] << 8) | d[at + 3]; + var found = new System.Collections.Generic.List(); + for (int i = 0, n = U16(4); i < n; i++) + { + var rec = 12 + 16 * i; + if (System.Text.Encoding.ASCII.GetString(d, rec, 4) != "name") continue; + var table = (int)U32(rec + 8); + int count = U16(table + 2), strings = table + U16(table + 4); + for (var j = 0; j < count; j++) + { + var r = table + 6 + 12 * j; + if (U16(r + 6) != nameId) continue; + var text = System.Text.Encoding.GetEncoding(U16(r) == 3 ? "utf-16BE" : "latin1").GetString(d, strings + U16(r + 10), U16(r + 8)); + found.Add(text.Replace("\r", "").Trim()); + } + } + return found; + } + private static string[] LicenceLikeFiles() { var root = Root(); return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) .Where(path => !path.EndsWith(".meta")) .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(); @@ -98,7 +151,7 @@ namespace Aetheria.Shared.Tests public void EveryCreditShowsTheHoldersItsOwnFileStates() { var credits = Load(); - Assert.Equal(Holders.Select(h => h.file).Append(Credits.NounProjectFile).OrderBy(f => f, StringComparer.Ordinal), + Assert.Equal(Holders.Select(h => h.file).Append(Credits.NounProjectFile).Concat(FontCredits.Select(f => f.credit)).OrderBy(f => f, StringComparer.Ordinal), credits.Select(credit => credit.LicenceFile).OrderBy(f => f, StringComparer.Ordinal)); foreach (var (file, stated) in Holders) { @@ -125,6 +178,47 @@ namespace Aetheria.Shared.Tests // 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(); + // The credit text is typed; the font files are the authority on who holds them. A wrong holder, or a font in the + // family that does not carry it, fails here. + [Fact] + public void FontCreditsShowWhatTheFontsThemselvesEmbed() + { + var credits = Load(); + foreach (var (creditFile, fonts, embedded) in FontCredits) + { + var notice = credits.Single(credit => credit.LicenceFile == creditFile).Notice; + Assert.Equal(creditFile, fonts[0]); + Assert.Contains(embedded, notice); + foreach (var font in fonts) + Assert.True(NameRecords(font, 0).Count > 0 && NameRecords(font, 0).All(record => record.StartsWith(embedded)), + $"{font} does not embed '{embedded}' as its copyright"); + } + Assert.Contains("Ubuntu Font Licence 1.0", NameRecords(Credits.UbuntuFontFile, 0)[0]); + Assert.Contains("Ubuntu Font Licence 1.0", credits.Single(credit => credit.LicenceFile == Credits.UbuntuFontFile).Licence); + Assert.Contains("SIL Open Font License, Version 1.1", NameRecords(Credits.MontserratFontFile, 13)[0]); + Assert.Contains("SIL Open Font License 1.1", credits.Single(credit => credit.LicenceFile == Credits.MontserratFontFile).Licence); + } + + [Fact] + public void EveryFontUnderAssetsIsCreditedOrReviewedAsUnreferenced() + { + var root = Root(); + var accounted = FontCredits.SelectMany(f => f.fonts).Concat(FontsCreditedByLicenceFile).Concat(ReviewedFonts.Keys).ToArray(); + var fonts = FontFiles(); + Assert.Equal(Array.Empty(), fonts.Where(font => !accounted.Contains(font)).ToArray()); + Assert.Equal(Array.Empty(), accounted.Where(font => !fonts.Contains(font)).ToArray()); + // A reviewed font stays out of the credits only while nothing outside Assets/Fonts uses it. + var guids = ReviewedFonts.Keys.ToDictionary(font => font, font => Regex.Match(Read(font + ".meta"), @"guid: (\w+)").Groups[1].Value); + Assert.All(guids.Values, guid => Assert.Equal(32, guid.Length)); + var users = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => !path.StartsWith("Assets/Fonts/") && new[] { ".prefab", ".unity", ".mat", ".asset", ".controller" }.Any(ext => path.EndsWith(ext))) + .Select(path => (path, text: Read(path))) + .ToArray(); + foreach (var (font, guid) in guids) + Assert.Equal(Array.Empty(), users.Where(user => user.text.Contains(guid)).Select(user => user.path).ToArray()); + } + [Fact] public void TwemojiComesFirst() { From a04c6414e1a644a63c55499b40e0345bd72df2c6 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 16:10:24 +0000 Subject: [PATCH 5/7] Credits test: follow a reviewed font to its SDF asset and fallback tables Prefabs reference the TMP SDF asset made from a font, not the .ttf, so matching the font file guid alone could never fail. The unreferenced-font check now follows the font guid and the guid of every Assets/Fonts file that references a guid already followed (the font's own SDF asset, any SDF asset listing it as a fallback); a file outside Assets/Fonts naming any of them is a use. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CreditsTests.cs | 27 +++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index 8d4a4a6b9..6e9869844 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -207,16 +207,29 @@ namespace Aetheria.Shared.Tests var fonts = FontFiles(); Assert.Equal(Array.Empty(), fonts.Where(font => !accounted.Contains(font)).ToArray()); Assert.Equal(Array.Empty(), accounted.Where(font => !fonts.Contains(font)).ToArray()); - // A reviewed font stays out of the credits only while nothing outside Assets/Fonts uses it. - var guids = ReviewedFonts.Keys.ToDictionary(font => font, font => Regex.Match(Read(font + ".meta"), @"guid: (\w+)").Groups[1].Value); - Assert.All(guids.Values, guid => Assert.Equal(32, guid.Length)); - var users = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + // A reviewed font stays out of the credits only while nothing outside Assets/Fonts uses it. Prefabs and scenes + // reference the TMP SDF asset made from the font, not the .ttf, so the search follows the font guid, then the + // guid of every file under Assets/Fonts that references a guid already followed: the font's own SDF asset, and + // any other SDF asset carrying it in a fallback table. A use is a file outside Assets/Fonts naming any of them. + var files = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) - .Where(path => !path.StartsWith("Assets/Fonts/") && new[] { ".prefab", ".unity", ".mat", ".asset", ".controller" }.Any(ext => path.EndsWith(ext))) + .Where(path => new[] { ".prefab", ".unity", ".mat", ".asset", ".controller" }.Any(ext => path.EndsWith(ext))) .Select(path => (path, text: Read(path))) .ToArray(); - foreach (var (font, guid) in guids) - Assert.Equal(Array.Empty(), users.Where(user => user.text.Contains(guid)).Select(user => user.path).ToArray()); + var inFonts = files.Where(file => file.path.StartsWith("Assets/Fonts/")).ToArray(); + var outside = files.Where(file => !file.path.StartsWith("Assets/Fonts/")).ToArray(); + foreach (var font in ReviewedFonts.Keys) + { + var followed = new System.Collections.Generic.HashSet { Regex.Match(Read(font + ".meta"), @"guid: (\w+)").Groups[1].Value }; + Assert.All(followed, guid => Assert.Equal(32, guid.Length)); + for (var grew = true; grew;) + { + grew = false; + foreach (var file in inFonts.Where(file => followed.Any(guid => file.text.Contains(guid))).ToArray()) + grew |= followed.Add(Regex.Match(Read(file.path + ".meta"), @"guid: (\w+)").Groups[1].Value); + } + Assert.Equal(Array.Empty(), outside.Where(user => followed.Any(guid => user.text.Contains(guid))).Select(user => user.path).ToArray()); + } } [Fact] From 43be42fb529b7dabcc65e5f94221352dae77708b Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 16:16:25 +0000 Subject: [PATCH 6/7] Credits: credit the works whose notice is only a source header; test scans headers Commit 16cb82d09 said every third-party work whose own text states holder and licence was credited. That was false: header-only notices were missed. Now credited, holder and licence taken from each file's own text: Academy of Motion Picture Arts and Sciences (ACES.hlsl, 2015), Unity Technologies 2016 MIT (the built-in shader sources Particle Standard Unlit, StretchedSkybox, MaskedWorldSpaceUI, MaskedWorldSpaceAdditiveUI and the three AdditiveUI shaders), Supyrb 2017 (AdditiveUI shaders; the header states no licence for that portion), and Stefan Gustavson 2011 MIT (Celestial Body SimplexNoise2.cginc). The discovery test now also scans every Copyright line in .shader/.hlsl/.cginc/.cs/.compute under Assets; its holder must appear in a credit notice or in ReviewedHeaders with the reason. Microsoft Open Technologies (UniRx headers, no licence stated, points at a License.txt that is not in the repo) is the one reviewed entry. The Unity headers point at a license.txt that is likewise not in the repo. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Credits.cs | 38 +++++++++++++++ tests/Aetheria.Shared.Tests/CreditsTests.cs | 53 ++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs index cfc3028fb..2746b6074 100644 --- a/Assets/Scripts/ServerShared/Credits.cs +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -27,6 +27,11 @@ public static class Credits 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"; + // Works whose notice is only a source-file header; each answers through the file whose header states it. + public const string UnityShaderFile = "Assets/Shaders/Particle Standard Unlit.shader"; + public const string AdditiveUiFile = "Assets/Shaders/AdditiveUI.shader"; + public const string AcesFile = "Assets/Shaders/CustomDoF/ACES.hlsl"; + public const string ClassicNoiseFile = "Assets/Plugins/Celestial Body/Scripts/Shaders/Includes/SimplexNoise2.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). @@ -100,6 +105,39 @@ public static class Credits LicenceFile = SimplexNoiseFile }, new Credit + { + Name = "Celestial Body noise shaders", + Licence = "MIT", + Notice = "Copyright (C) 2011 Ashima Arts. Copyright (C) 2011 by Ashima Arts (Simplex noise). " + + "Copyright (C) 2011 by Stefan Gustavson (Classic noise).", + LicenceFile = ClassicNoiseFile + }, + new Credit + { + Name = "Unity built-in shaders", + Licence = "MIT", + Notice = "Copyright (c) 2016 Unity Technologies. Shader source adapted from Unity's built-in shaders " + + "(Particle Standard Unlit, StretchedSkybox, MaskedWorldSpaceUI, MaskedWorldSpaceAdditiveUI).", + LicenceFile = UnityShaderFile + }, + new Credit + { + Name = "AdditiveUI shaders", + Licence = "MIT for the Unity Technologies portion; the Supyrb portion states no licence", + Notice = "Copyright (c) 2017 Supyrb. All rights reserved. Author: Johannes Deml. " + + "Built on Unity's built-in UI shader, copyright (c) 2016 Unity Technologies, MIT.", + LicenceFile = AdditiveUiFile + }, + new Credit + { + Name = "ACES colour transforms", + Licence = "Academy Color Encoding System licence", + Notice = "Copyright 2015 Academy of Motion Picture Arts and Sciences (A.M.P.A.S.). " + + "Portions contributed by others as indicated. All rights reserved. " + + "Source: https://github.com/ampas/aces-dev", + LicenceFile = AcesFile + }, + new Credit { Name = "ambientCG PBR materials", Licence = "CC0 1.0 Universal", diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index 6e9869844..625820571 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -34,6 +34,12 @@ namespace Aetheria.Shared.Tests ["Assets/Plugins/Celestial Body/Textures/_Sources.txt"] = "URLs of the source textures only; states no licence", }; + // Copyright lines found in source headers whose holder no credit shows, each with the reason; keyed like HeaderKeys. + private static readonly System.Collections.Generic.Dictionary ReviewedHeaders = new System.Collections.Generic.Dictionary + { + ["microsoftopen"] = "UniRx files derived from Rx.NET: the header names Microsoft Open Technologies and points at a License.txt that is not in the repo, so no licence is stated; decision owed", + }; + // 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 = @@ -55,6 +61,10 @@ namespace Aetheria.Shared.Tests (Credits.KdTreeFile, new[] { "Vili Vol\u010dini / viliwonka" }), (Credits.SimplexNoiseFile, new[] { "Ashima Arts", "Ian McEwan", "Keijiro Takahashi" }), (Credits.AmbientCgFile, new[] { "ambientCG" }), + (Credits.UnityShaderFile, new[] { "Unity Technologies" }), + (Credits.AdditiveUiFile, new[] { "Unity Technologies", "Supyrb", "Johannes Deml" }), + (Credits.AcesFile, new[] { "Academy of Motion Picture Arts and Sciences", "A.M.P.A.S." }), + (Credits.ClassicNoiseFile, new[] { "Ashima Arts", "Stefan Gustavson" }), }; // Fonts that state holder and licence in their own name tables: the credit's LicenceFile is the first font, and @@ -130,6 +140,45 @@ namespace Aetheria.Shared.Tests private static string[] Unaccounted(string[] found, System.Collections.Generic.IEnumerable credited) => found.Where(path => !credited.Contains(path) && !ReviewedNotCredited.ContainsKey(path)).ToArray(); + // The holder keys of every "Copyright ..." line in the source files under Assets: the year (when there is one) and + // the first two words of the holder, letters and digits only, lower case. Credits.cs is the credit text itself. + private static readonly Regex HeaderCopyright = new Regex(@"Copyright\s*(?:\([cC]\))?\s*(?:by\s+)?[<\[]?((?:\d{4}|[A-Z])[^\r\n]*)", RegexOptions.CultureInvariant); + + private static string Key(string text) => new string(text.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + + private static System.Collections.Generic.Dictionary HeaderKeys() + { + var root = Root(); + var keys = new System.Collections.Generic.Dictionary(); + foreach (var path in Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => new[] { ".shader", ".hlsl", ".cginc", ".cs", ".compute" }.Any(ext => path.EndsWith(ext)) && path != "Assets/Scripts/ServerShared/Credits.cs")) + foreach (var line in Read(path).Split('\n')) + foreach (Match match in HeaderCopyright.Matches(line)) + { + var words = Regex.Replace(Regex.Replace(match.Groups[1].Value, @"\.\s.*", ""), @"[<>,]", " ").Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(word => word != "by").ToArray(); + var year = words.Length > 0 && Regex.IsMatch(words[0], @"^\d{4}") ? 1 : 0; + if (year == 0 && !Regex.IsMatch(line, @"\([cC]\)")) continue; + keys.TryAdd(Key(string.Concat(words.Take(year + 2))), path); + } + return keys; + } + + // A copyright line in a source header is a notice the licence requires shown, so its holder is in a credit or in + // ReviewedHeaders with the reason; a removed credit, or a newly pasted header, fails here. + [Fact] + public void EveryCopyrightHolderInASourceHeaderIsCreditedOrReviewed() + { + var keys = HeaderKeys(); + Assert.Contains("2015academyof", keys.Keys); + Assert.Contains("2016unitytechnologies", keys.Keys); + Assert.Contains("2017supyrb", keys.Keys); + var shown = Key(string.Join(" ", Load().Select(credit => Normal(credit.Notice)))); + Assert.Equal(Array.Empty(), keys.Where(k => !shown.Contains(k.Key) && !ReviewedHeaders.ContainsKey(k.Key)).Select(k => $"{k.Key} in {k.Value}").ToArray()); + Assert.Equal(Array.Empty(), ReviewedHeaders.Keys.Where(k => !keys.ContainsKey(k)).ToArray()); + } + [Fact] public void EveryLicenceLikeFileUnderAssetsIsCreditedOrReviewed() { @@ -167,7 +216,7 @@ namespace Aetheria.Shared.Tests { 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); + var body = Regex.Replace(match.Groups[1].Value.Replace("[", "").Replace("]", "").Trim(), @"(\.\s.*|\s*All rights reserved)?\.?$", "", RegexOptions.IgnoreCase); Assert.True(notice.Contains(Normal(body)), $"the credit for {file} omits its copyright line '{body}'"); } } @@ -176,7 +225,7 @@ namespace Aetheria.Shared.Tests 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(); + private static string Normal(string text) => text.Replace('\u2013', '-').Replace('\u2014', '-').Replace("\r", "").Replace(" by ", " ").ToLowerInvariant(); // The credit text is typed; the font files are the authority on who holds them. A wrong holder, or a font in the // family that does not carry it, fails here. From 63403c8441afe5724208a7fc6b8d4b131a589b43 Mon Sep 17 00:00:00 2001 From: Nibu Date: Thu, 12 Nov 2026 16:20:52 +0000 Subject: [PATCH 7/7] Credits: ship the Ubuntu Font Licence 1.0 and SIL OFL 1.1 texts beside the fonts UFL-1.0.txt is the upstream Ubuntu Font Licence 1.0 text (google/fonts ufl/ubuntu/UFL.txt). Montserrat-OFL-1.1.txt is the OFL 1.1 body, byte-for-byte the text already shipped with Liberation Sans, headed by the copyright the Montserrat font embeds (Copyright(c) 2014 by Julieta Ulanovsky) rather than the project's later authors line. The Ubuntu and Montserrat credits now name these files as their LicenceFile; the test checks each carries its licence, sits beside its fonts, and (OFL) the embedded holder. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt | 93 ++++++++++++++++++ .../Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta | 7 ++ Assets/Fonts/Ubuntu/UFL-1.0.txt | 96 +++++++++++++++++++ Assets/Fonts/Ubuntu/UFL-1.0.txt.meta | 7 ++ Assets/Scripts/ServerShared/Credits.cs | 10 +- tests/Aetheria.Shared.Tests/CreditsTests.cs | 23 +++-- 6 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt create mode 100644 Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta create mode 100644 Assets/Fonts/Ubuntu/UFL-1.0.txt create mode 100644 Assets/Fonts/Ubuntu/UFL-1.0.txt.meta diff --git a/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt new file mode 100644 index 000000000..d2fcdf3eb --- /dev/null +++ b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright(c) 2014 by Julieta Ulanovsky + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta new file mode 100644 index 000000000..c2ea6a451 --- /dev/null +++ b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dc84a6b2454445f9be69af26e86eb90c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Fonts/Ubuntu/UFL-1.0.txt b/Assets/Fonts/Ubuntu/UFL-1.0.txt new file mode 100644 index 000000000..ae78a8f94 --- /dev/null +++ b/Assets/Fonts/Ubuntu/UFL-1.0.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/Assets/Fonts/Ubuntu/UFL-1.0.txt.meta b/Assets/Fonts/Ubuntu/UFL-1.0.txt.meta new file mode 100644 index 000000000..33790c1ab --- /dev/null +++ b/Assets/Fonts/Ubuntu/UFL-1.0.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 331370618a0a473fb6a6837985b38a35 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs index 2746b6074..45054187b 100644 --- a/Assets/Scripts/ServerShared/Credits.cs +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -20,9 +20,9 @@ public static class Credits 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"; - // Fonts state their holder and licence in their own name tables; these are the font files the credit answers for. - public const string UbuntuFontFile = "Assets/Fonts/Ubuntu/Ubuntu-R.ttf"; - public const string MontserratFontFile = "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf"; + // Fonts state their holder and licence in their own name tables; the licence texts shipped beside them are these. + public const string UbuntuLicenceFile = "Assets/Fonts/Ubuntu/UFL-1.0.txt"; + public const string MontserratLicenceFile = "Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.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"; @@ -72,14 +72,14 @@ public static class Credits Name = "Ubuntu and Ubuntu Mono fonts", Licence = "Ubuntu Font Licence 1.0", Notice = "Copyright 2011 Canonical Ltd.", - LicenceFile = UbuntuFontFile + LicenceFile = UbuntuLicenceFile }, new Credit { Name = "Montserrat", Licence = "SIL Open Font License 1.1", Notice = "Copyright(c) 2014 by Julieta Ulanovsky", - LicenceFile = MontserratFontFile + LicenceFile = MontserratLicenceFile }, new Credit { diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index 625820571..68f9d68da 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -23,7 +23,7 @@ namespace Aetheria.Shared.Tests // 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); + @"^(LICEN[CS]E|COPYING|NOTICE|README|SOURCES|_SOURCES|Attribution|UFL)|OFL", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); // 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. @@ -67,14 +67,14 @@ namespace Aetheria.Shared.Tests (Credits.ClassicNoiseFile, new[] { "Ashima Arts", "Stefan Gustavson" }), }; - // Fonts that state holder and licence in their own name tables: the credit's LicenceFile is the first font, and - // every font listed must embed the stated string (read from the file, not from the credit) and be shown by the + // Fonts that state holder and licence in their own name tables: the credit's LicenceFile is the licence text shipped + // beside them (which must carry the licence and the stated holder), and every font listed must embed the stated string (read from the file, not from the credit) and be shown by the // credit. The ids are TrueType name ids: 0 is the copyright notice. private static readonly (string credit, string[] fonts, string embedded)[] FontCredits = { - (Credits.UbuntuFontFile, new[] { "Assets/Fonts/Ubuntu/Ubuntu-R.ttf", "Assets/Fonts/Ubuntu/Ubuntu-L.ttf", "Assets/Fonts/Ubuntu/Ubuntu-B.ttf", "Assets/Fonts/Ubuntu/UbuntuMono-R.ttf" }, + (Credits.UbuntuLicenceFile, new[] { "Assets/Fonts/Ubuntu/Ubuntu-R.ttf", "Assets/Fonts/Ubuntu/Ubuntu-L.ttf", "Assets/Fonts/Ubuntu/Ubuntu-B.ttf", "Assets/Fonts/Ubuntu/UbuntuMono-R.ttf" }, "Copyright 2011 Canonical Ltd."), - (Credits.MontserratFontFile, new[] { "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf" }, "Copyright(c) 2014 by Julieta Ulanovsky"), + (Credits.MontserratLicenceFile, new[] { "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf" }, "Copyright(c) 2014 by Julieta Ulanovsky"), }; // Fonts credited through a licence file in the repo (see Holders), not through their own text. @@ -236,16 +236,19 @@ namespace Aetheria.Shared.Tests foreach (var (creditFile, fonts, embedded) in FontCredits) { var notice = credits.Single(credit => credit.LicenceFile == creditFile).Notice; - Assert.Equal(creditFile, fonts[0]); + var licenceText = Read(creditFile); + Assert.Contains(creditFile.Contains("UFL") ? "UBUNTU FONT LICENCE Version 1.0" : "SIL OPEN FONT LICENSE Version 1.1", licenceText); + Assert.True(Path.GetDirectoryName(creditFile) == Path.GetDirectoryName(fonts[0]).Replace('\\', '/'), "licence text sits beside its fonts"); + if (creditFile.Contains("OFL")) Assert.Contains(embedded, licenceText); Assert.Contains(embedded, notice); foreach (var font in fonts) Assert.True(NameRecords(font, 0).Count > 0 && NameRecords(font, 0).All(record => record.StartsWith(embedded)), $"{font} does not embed '{embedded}' as its copyright"); } - Assert.Contains("Ubuntu Font Licence 1.0", NameRecords(Credits.UbuntuFontFile, 0)[0]); - Assert.Contains("Ubuntu Font Licence 1.0", credits.Single(credit => credit.LicenceFile == Credits.UbuntuFontFile).Licence); - Assert.Contains("SIL Open Font License, Version 1.1", NameRecords(Credits.MontserratFontFile, 13)[0]); - Assert.Contains("SIL Open Font License 1.1", credits.Single(credit => credit.LicenceFile == Credits.MontserratFontFile).Licence); + Assert.Contains("Ubuntu Font Licence 1.0", NameRecords(FontCredits[0].fonts[0], 0)[0]); + Assert.Contains("Ubuntu Font Licence 1.0", credits.Single(credit => credit.LicenceFile == Credits.UbuntuLicenceFile).Licence); + Assert.Contains("SIL Open Font License, Version 1.1", NameRecords(FontCredits[1].fonts[0], 13)[0]); + Assert.Contains("SIL Open Font License 1.1", credits.Single(credit => credit.LicenceFile == Credits.MontserratLicenceFile).Licence); } [Fact]