Aetheria/tests/Aetheria.Shared.Tests/SubstanceCleanBreakTests.cs
Nibu 83e6957de0 Test: guid resolution requires the asset beside the .meta
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 08:41:38 +00:00

253 lines
13 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Xunit;
// Substance clean break (aetheria-release:ruling:clean-break-from-substance, cut remove-sbsar-files). The 41 .sbsar
// files, their importer and the SUBSTANCE_PLUGIN_ENABLED define are gone, and nothing that survives may point into
// them. Headless: no Unity is open, so a dangling GUID is caught here instead of rendering pink in the editor.
public sealed class SubstanceCleanBreakTests
{
// Every guid the removed tree owned, captured from the .meta files before deletion: the 41 .sbsar.meta, the two
// folder metas (Assets/Materials/Substance.meta, Substance/Metal.meta) and the importer script guid
// (43a8bbf6..., the `script:` line of every .sbsar.meta, whose plugin was already absent).
private static readonly string[] Tombstones =
{
"06833951ebae4cc45ad88d58cb5571de",
"06d6a565d43fba5409712ce9a10bfbef",
"07f679eadba9fb14abac26b1e165f7d5",
"092bdd8d243488141aeab5bb0d72429a",
"09aee558716a08d4c9806b9b4273a209",
"0b5dfc86c0681a3459a57d197804191b",
"0be5fb1df83ac8e499a3f6a060041e49",
"130f566ffa0b2424fbb47de2194c136a",
"180b85899b4b1f848b348230339ce984",
"1c858fec5b74c7c46b1712494192b6b5",
"26a894e543f3ed44893b1620707ca62c",
"2b42584452fe0cc4fa4e8a115230b156",
"2be0f689f5b01be4aa1032bc86f15eab",
"2e9a9dbacde66a446852af882f33e9e2",
"43a8bbf65be290d44ba52b94476242b9",
"4e39297ae4fe9ee4b803c04eabb69da7",
"50192727b92e0af4cbe5c773bec3537f",
"504c7bfe0d50ff64a88b8eac2ceb46f8",
"53adf8e53222e6f4791d7bf2110fb70c",
"58e16b7dbc1c31f4585e11544cdce127",
"6b2eb94965ca27542b8627d3521f5b5b",
"7363fcc4a3c41a349b29de7ec34555be",
"7de1c11cf7591ec48bdb53e9818172f5",
"8249e9ffcbd97c84d8ce73343e156c82",
"885e680924fc12c439a842a2142d4b78",
"9458f33b8e7e51a42aa19686b9779d82",
"9aa51bbe4dd46874a906608c34a739e4",
"9d0963470d3545a4e932ed4ea95b9d62",
"9fc901afb8a434e46b9405eaec2df5a4",
"a1d39ea984432bf44b4a0686afe2bf1a",
"a74970be876d3554ea0f279f39314c9c",
"afbd691d0ea2a5a47b5a43b244593a56",
"b2bec4b400153d745b2915f1c43e5c96",
"b7e648471e9dfc44490bd41973711541",
"c00b5a4f1e4743a4ca59b8030251f900",
"c5f2177e1f35daa499f4d7e0b7f3370a",
"ca8f080ab60226e4097841eee2bfa8db",
"cbc61995a46604441ab9de4f1c68e290",
"d15fc45ed0000934d946b5f271f637ef",
"ddc6970cc883b9845bdadc91983130c2",
"deb7e15e5f95294498d18869d9f11302",
"e7758cad28ae98f478940e55222419a6",
"ea7457b9a4f0bcb43becaa33de541cbd",
"ef9a54b8dacea9f44be70da43472f701",
};
private static readonly string[] PrefabsAndMetasEditedByTheCut =
{
"Assets/Content/Prefabs/Turrets/Turret.prefab", "Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab",
"Assets/Prefabs/RPG/Pickups/Hexahedron.prefab", "Assets/Prefabs/RPG/Pickups/Icosahedron.prefab",
"Assets/Prefabs/RPG/Pickups/Octahedron.prefab", "Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab",
"Assets/Models/Zenith_Blender.fbx.meta",
};
// A material reference (main object fileID 2100000). Script guids in the same files resolve to Unity packages,
// which have no .meta under Assets/, so only material slots are checked.
private static readonly Regex MaterialRef = new(@"fileID:\s*2100000,\s*guid:\s*([0-9a-f]{32})", RegexOptions.Compiled);
private static readonly string[] ExcludedDirs = { ".git", "node_modules", ".voidbot" };
private static readonly string[] RepointedMaterials =
{
"Assets/Materials/Steel.mat", "Assets/Materials/Aluminium brushed.mat", "Assets/Materials/Silver.mat",
"Assets/Materials/Steel parkerized.mat", "Assets/Materials/Cockpit.mat", "Assets/Materials/Metal 003.mat",
"Assets/Materials/Car Paint Red.mat", "Assets/Materials/Car Paint Magenta.mat",
"Assets/Materials/Car Paint White.mat", "Assets/Materials/Car Paint White Small.mat",
"Assets/Materials/Radiators.mat", "Assets/Materials/Station Lights.mat", "Assets/Materials/Mine Glow.mat",
"Assets/Models/Discobolus/Discobolus Marble.mat", "Assets/Materials/Pickup Trim.mat",
};
private static IEnumerable<string> Walk(string dir)
{
foreach (var file in Directory.EnumerateFiles(dir)) yield return file;
foreach (var sub in Directory.EnumerateDirectories(dir))
if (!ExcludedDirs.Contains(Path.GetFileName(sub)))
foreach (var file in Walk(sub)) yield return file;
}
// A checkout that holds Git LFS pointers instead of content cannot be scanned. The guards that read LFS-tracked
// files fail loudly here: skipping would pass vacuously, and a failure says "smudge the LFS objects first".
private static bool IsLfsPointer(string path)
{
using var stream = File.OpenRead(path);
var head = new byte[48];
var n = stream.Read(head, 0, head.Length);
return Encoding.ASCII.GetString(head, 0, n).StartsWith("version https://git-lfs", StringComparison.Ordinal);
}
private static string ReadTextOrFailOnPointer(string path)
{
Assert.False(IsLfsPointer(path), $"{path} is an LFS pointer, not content: this guard cannot see inside it. Run `git lfs pull`.");
return File.ReadAllText(path, Encoding.Latin1);
}
// Everything is scanned except known binary media (a denylist, so an unlisted serialized type is still read) and,
// for unlisted extensions, files with a NUL byte in the first 8 KiB. The extension check comes first because an
// LFS pointer for a texture is itself text and says nothing about what the object holds.
private static readonly string[] BinaryExtensions =
{
".png", ".jpg", ".jpeg", ".tga", ".tif", ".tiff", ".psd", ".bmp", ".gif", ".exr", ".hdr", ".dds", ".wav", ".mp3",
".ogg", ".mp4", ".fbx", ".glb", ".blend", ".ttf", ".otf", ".dll", ".so", ".pdb", ".msgpack", ".zip", ".bank",
};
private static bool LooksBinary(string path)
{
if (BinaryExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) return true;
using var stream = File.OpenRead(path);
var buf = new byte[8192];
var n = stream.Read(buf, 0, buf.Length);
return Array.IndexOf(buf, (byte)0, 0, n) >= 0;
}
private static Dictionary<string, string> GuidToMeta(string root)
{
var map = new Dictionary<string, string>();
foreach (var meta in Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*.meta", SearchOption.AllDirectories))
{
var guid = File.ReadLines(meta).FirstOrDefault(l => l.StartsWith("guid: "))?.Substring("guid: ".Length).Trim();
if (guid == null) continue;
// A .meta whose asset is gone still carries the guid, so resolution must look beside it: the asset is the
// .meta path minus ".meta". Directory metas resolve to a folder.
var asset = meta.Substring(0, meta.Length - ".meta".Length);
if (File.Exists(asset) || Directory.Exists(asset)) map[guid] = Path.GetRelativePath(root, meta).Replace('\\', '/');
}
return map;
}
[Fact]
public void NoSbsarFileOrMetaRemains()
{
var root = RestoredHullsTests.FindRepoRoot();
var stray = Walk(root).Where(f => f.EndsWith(".sbsar", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".sbsar.meta", StringComparison.OrdinalIgnoreCase))
.Select(f => Path.GetRelativePath(root, f)).ToList();
Assert.True(stray.Count == 0, $"{stray.Count} Substance archive files remain, first: {stray.FirstOrDefault()}");
Assert.False(Directory.Exists(Path.Combine(root, "Assets", "Materials", "Substance")));
}
[Fact]
public void NoSurvivingFileNamesARemovedSubstanceGuid()
{
var root = RestoredHullsTests.FindRepoRoot();
var files = new[] { "Assets", "Packages", "ProjectSettings" }
.Select(d => Path.Combine(root, d)).Where(Directory.Exists)
.SelectMany(Walk)
.Concat(Directory.Exists(Path.Combine(root, "GameData"))
? Directory.EnumerateFiles(Path.Combine(root, "GameData"), "*.cc") : Enumerable.Empty<string>())
.Where(f => f.EndsWith(".cc") || !LooksBinary(f))
.ToList();
var pointers = files.Where(IsLfsPointer).Select(f => Path.GetRelativePath(root, f)).ToList();
Assert.True(pointers.Count == 0,
$"{pointers.Count} scanned files are LFS pointers, not content (first: {pointers.FirstOrDefault()}); run `git lfs pull`.");
var hits = new List<string>();
foreach (var file in files)
{
var lines = File.ReadAllText(file, Encoding.Latin1).Split('\n');
for (var i = 0; i < lines.Length; i++)
foreach (var guid in Tombstones)
if (lines[i].Contains(guid, StringComparison.Ordinal))
hits.Add($"{Path.GetRelativePath(root, file)}:{i + 1} names {guid}");
}
Assert.True(hits.Count == 0, $"{hits.Count} references to removed Substance guids:\n" + string.Join("\n", hits.Take(40)));
}
[Fact]
public void NoSubstanceBuildSurface()
{
var root = RestoredHullsTests.FindRepoRoot();
Assert.DoesNotContain("SUBSTANCE", ReadTextOrFailOnPointer(Path.Combine(root, "ProjectSettings", "ProjectSettings.asset")), StringComparison.Ordinal);
foreach (var name in new[] { "manifest.json", "packages-lock.json" })
{
var text = File.ReadAllText(Path.Combine(root, "Packages", name));
Assert.DoesNotContain("substance", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("allegorithmic", text, StringComparison.OrdinalIgnoreCase);
}
Assert.DoesNotContain("sbsar", File.ReadAllText(Path.Combine(root, ".gitattributes")), StringComparison.OrdinalIgnoreCase);
Assert.False(Directory.Exists(Path.Combine(root, "Assets", "Allegorithmic")));
}
[Fact]
public void EveryReplacementTextureResolves()
{
var root = RestoredHullsTests.FindRepoRoot();
var guidToMeta = GuidToMeta(root);
var texture = new Regex(@"m_Texture:\s*\{fileID:\s*(-?\d+)(?:,\s*guid:\s*([0-9a-f]{32}))?", RegexOptions.Compiled);
var problems = new List<string>();
foreach (var rel in RepointedMaterials)
{
var path = Path.Combine(root, rel);
if (!File.Exists(path)) { problems.Add($"{rel} does not exist"); continue; }
var text = File.ReadAllText(path);
var start = text.IndexOf("m_TexEnvs:", StringComparison.Ordinal);
var end = text.IndexOf("m_Floats:", StringComparison.Ordinal);
Assert.True(start >= 0 && end > start, $"{rel} has no m_TexEnvs section");
foreach (Match m in texture.Matches(text.Substring(start, end - start)))
{
if (m.Groups[1].Value == "0") continue;
if (!m.Groups[2].Success) { problems.Add($"{rel} has a nonzero texture fileID with no guid: {m.Value}"); continue; }
var guid = m.Groups[2].Value;
if (!guidToMeta.TryGetValue(guid, out var meta)) problems.Add($"{rel} names {guid}, which has no .meta and asset under Assets/");
else if (meta.StartsWith("Assets/Materials/Substance", StringComparison.Ordinal)) problems.Add($"{rel} names {guid} inside the removed Substance tree");
else if (!File.ReadLines(Path.Combine(root, meta)).Any(l => l.StartsWith("TextureImporter:", StringComparison.Ordinal)))
problems.Add($"{rel} texture slot names {guid} ({meta}), which is not a TextureImporter asset");
}
}
Assert.True(problems.Count == 0, string.Join("\n", problems));
}
[Fact]
public void EveryRepointedPrefabAndFbxSlotResolves()
{
var root = RestoredHullsTests.FindRepoRoot();
var guidToMeta = GuidToMeta(root);
var problems = new List<string>();
foreach (var rel in PrefabsAndMetasEditedByTheCut)
{
var path = Path.Combine(root, rel);
if (!File.Exists(path)) { problems.Add($"{rel} does not exist"); continue; }
var lines = ReadTextOrFailOnPointer(path).Split('\n');
for (var i = 0; i < lines.Length; i++)
foreach (Match m in MaterialRef.Matches(lines[i]))
{
var guid = m.Groups[1].Value;
if (!guidToMeta.TryGetValue(guid, out var meta)) problems.Add($"{rel}:{i + 1} names {guid}, which has no .meta and asset under Assets/");
else if (!meta.EndsWith(".mat.meta", StringComparison.Ordinal)) problems.Add($"{rel}:{i + 1} material slot names {guid} ({meta}), which is not a .mat");
}
}
Assert.True(problems.Count == 0, string.Join("\n", problems.Take(40)));
}
}