Credits screen: one attribution list, reachable from the main menu #16

Merged
nibu merged 7 commits from nibu/credits-screen into master 2026-11-12 16:27:17 +00:00
10 changed files with 848 additions and 14 deletions

View file

@ -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.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dc84a6b2454445f9be69af26e86eb90c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 331370618a0a473fb6a6837985b38a35
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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

@ -0,0 +1,180 @@
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";
public const string LiberationFile = "Assets/TextMesh Pro/Fonts/LiberationSans - OFL.txt";
// 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";
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).
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<Credit> All(string nounProjectAttribution)
{
var credits = new List<Credit>
{
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 = "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 = "Ubuntu and Ubuntu Mono fonts",
Licence = "Ubuntu Font Licence 1.0",
Notice = "Copyright 2011 Canonical Ltd.",
LicenceFile = UbuntuLicenceFile
},
new Credit
{
Name = "Montserrat",
Licence = "SIL Open Font License 1.1",
Notice = "Copyright(c) 2014 by Julieta Ulanovsky",
LicenceFile = MontserratLicenceFile
},
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 = "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",
Notice = "Texture sets from ambientCG (https://ambientcg.com), public domain",
LicenceFile = AmbientCgFile
},
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, 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)
{
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");
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3333d3c0203441fbadd64fad27b60e5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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;
@ -136,9 +138,79 @@ public class MainMenu : MonoBehaviour
ShowSettings();
Fade(true);
});
_nextMenu.panel.AddButton("Credits",
() =>
{
if (ShowCredits()) Fade(true);
});
_nextMenu.panel.AddButton("Quit", Application.Quit);
}
// 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 attribution = Resources.Load<TextAsset>(Credits.NounProjectResource);
if (attribution == null)
{
Refuse("Credits are unavailable", $"{Credits.NounProjectResource} is missing from this build.");
return false;
}
IReadOnlyList<Credit> credits;
try { credits = Credits.All(attribution.text); }
catch (InvalidOperationException error)
{
Refuse("Credits are unavailable", error.Message);
return false;
}
Next("credits");
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;
list.AddButton(credits[i].Name, () => Select(index)).Button.navigation = new Navigation { mode = Navigation.Mode.None };
}
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
// to set up a run and the test arenas are listed only in editor and development builds.
private void ShowScenarios()

View file

@ -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<Scenario> 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);
}

View file

@ -0,0 +1,363 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
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<Credit> Load() => Credits.All(Read(Credits.NounProjectFile));
// 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|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.
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",
};
// 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<string, string> ReviewedHeaders = new System.Collections.Generic.Dictionary<string, string>
{
["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 =
{
"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" }),
(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 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.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.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.
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<string, string> ReviewedFonts = new System.Collections.Generic.Dictionary<string, string>
{
["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<string> 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<string>();
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 => LicenceLike.IsMatch(Path.GetFileName(path)))
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
}
// 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();
// 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<string, string> HeaderKeys()
{
var root = Root();
var keys = new System.Collections.Generic.Dictionary<string, string>();
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<string>(), keys.Where(k => !shown.Contains(k.Key) && !ReviewedHeaders.ContainsKey(k.Key)).Select(k => $"{k.Key} in {k.Value}").ToArray());
Assert.Equal(Array.Empty<string>(), ReviewedHeaders.Keys.Where(k => !keys.ContainsKey(k)).ToArray());
}
[Fact]
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).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)
{
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.*|\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", "").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.
[Fact]
public void FontCreditsShowWhatTheFontsThemselvesEmbed()
{
var credits = Load();
foreach (var (creditFile, fonts, embedded) in FontCredits)
{
var notice = credits.Single(credit => credit.LicenceFile == creditFile).Notice;
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(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]
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<string>(), fonts.Where(font => !accounted.Contains(font)).ToArray());
Assert.Equal(Array.Empty<string>(), accounted.Where(font => !fonts.Contains(font)).ToArray());
// 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 => new[] { ".prefab", ".unity", ".mat", ".asset", ".controller" }.Any(ext => path.EndsWith(ext)))
.Select(path => (path, text: Read(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<string> { 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<string>(), outside.Where(user => followed.Any(guid => user.text.Contains(guid))).Select(user => user.path).ToArray());
}
}
[Fact]
public void TwemojiComesFirst()
{
var twemoji = Load()[0];
Assert.Equal("Twemoji", twemoji.Name);
Assert.Contains("CC BY 4.0", twemoji.Licence);
}
// 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 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()
{
foreach (var credit in Load())
{
Assert.False(string.IsNullOrWhiteSpace(credit.Name));
Assert.False(string.IsNullOrWhiteSpace(credit.Licence));
Assert.False(string.IsNullOrWhiteSpace(credit.Notice));
}
}
[Fact]
public void AnEmptyEntryIsRefused()
{
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 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 = 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 bool ShowCredits()", StringComparison.Ordinal));
Assert.Contains("AddButton(\"Credits\"", main);
Assert.Contains("ShowCredits()", main);
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);
}
}
}