using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using GameCult.Caching; using MessagePack; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEngine; using Object = UnityEngine.Object; // docs/addressables-cut.md §2.5: validates every [CultInspectorAssetGuid] string member stored in // the catalog. Read-only: it opens the catalog through AetheriaStores.Open exactly like the game // does, and never writes. No repair path — a failure names the record, member, GUID and path. public static class EngineAssetCheck { // One row per attributed member (docs/addressables-cut.md §2.5 point 4). A member found on a // stored document but missing here fails the check, so a new field can't skip validation. private delegate bool Contract(object owner, Type ownerType, Object asset, out string reason); private static readonly Dictionary ConsumerContracts = new Dictionary { ["Icon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), ["ActionBarIcon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), ["Schematic"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), ["Logo"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract(asset, out reason), ["Prefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), ["ParticlesPrefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), ["Particles"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract(asset as GameObject, out reason), ["EffectPrefab"] = EffectPrefabContract, }; // Menu entry for interactive use; -executeMethod EngineAssetCheck.Run for batchmode/CI, exit 1 on failure. [MenuItem("Aetheria/Validate Engine Asset References")] public static void Run() { var failures = RunCheck(out var checkedCount); foreach (var failure in failures) Debug.LogError(failure); Debug.Log(failures.Count == 0 ? $"EngineAssetCheck: {checkedCount} stored reference(s) OK." : $"EngineAssetCheck: {failures.Count} failure(s) of {checkedCount} checked."); if (Application.isBatchMode) EditorApplication.Exit(failures.Count == 0 ? 0 : 1); } // Returns one failure line per bad stored reference; empty when everything resolves. Exposed for the // mutation tests (tests/mutation_tests.py) driving this same method through a batchmode -executeMethod. public static List RunCheck(out int checkedCount) { var failures = new List(); checkedCount = 0; var root = Directory.GetParent(Application.dataPath)!.FullName; var catalogPath = Path.Combine(root, "GameData", "Aetheria.cc"); var cache = AetheriaStores.Open(catalogPath); try { foreach (var stored in cache.AllStoredDocuments.ToArray()) { var docType = stored.Descriptor.DocumentType; var document = stored.Document; var name = NameOf(document, docType); var seen = new HashSet(); foreach (var (owner, field) in WalkAssetGuidFields(document, seen)) { var key = (string)field.GetValue(owner); if (string.IsNullOrEmpty(key)) continue; checkedCount++; var label = $"{docType.Name} \"{name}\" .{field.Name} = \"{key}\""; if (!CultAssetGuidKey.TryParse(key, out var guid, out var subAssetName)) { failures.Add($"{label}: not a valid Addressables key (guid or guid[subAsset])"); continue; } if (guid.Length != 32 || !guid.All(IsLowerHex)) { failures.Add($"{label}: GUID \"{guid}\" is not 32 lowercase hex characters"); continue; } var path = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(path) || !File.Exists(Path.Combine(root, path))) { failures.Add($"{label}: GUID {guid} names no existing file"); continue; } var entry = AddressableAssetSettingsDefaultObject.Settings?.FindAssetEntry(guid, true); if (entry == null) { failures.Add($"{label}: {path} ({guid}) is not addressable"); continue; } if (!ConsumerContracts.TryGetValue(field.Name, out var contract)) { failures.Add($"{label}: no consumer contract registered for member \"{field.Name}\"; add one to EngineAssetCheck.ConsumerContracts"); continue; } Object asset = string.IsNullOrEmpty(subAssetName) ? AssetDatabase.LoadAssetAtPath(path) : AssetDatabase.LoadAllAssetsAtPath(path).FirstOrDefault(a => a.name == subAssetName); if (asset == null) { failures.Add($"{label}: {(string.IsNullOrEmpty(subAssetName) ? path : path + "[" + subAssetName + "]")} did not load"); continue; } if (!contract(owner, owner.GetType(), asset, out var reason)) failures.Add($"{label}: {reason}"); } } return failures; } finally { cache.Dispose(); } } private static bool TypeContract(Object asset, out string reason) where T : Object { if (!(asset is T)) { reason = $"asset is a {asset.GetType().Name}, not a {typeof(T).Name}"; return false; } reason = null; return true; } private static bool ComponentContract(GameObject gameObject, out string reason) where T : Component { if (gameObject == null) { reason = "asset is not a GameObject"; return false; } if (gameObject.GetComponent() == null) { reason = $"GameObject has no {typeof(T).Name}"; return false; } reason = null; return true; } private static bool EffectPrefabContract(object owner, Type ownerType, Object asset, out string reason) { var gameObject = asset as GameObject; if (typeof(InstantWeaponData).IsAssignableFrom(ownerType)) return ComponentContract(gameObject, out reason); if (typeof(ConstantWeaponData).IsAssignableFrom(ownerType)) return ComponentContract(gameObject, out reason); reason = $"unrecognised WeaponData subtype {ownerType.Name} for EffectPrefab"; return false; } private static bool IsLowerHex(char c) => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); private static string NameOf(object document, Type docType) { var nameField = docType.GetField("Name", BindingFlags.Public | BindingFlags.Instance); return nameField?.GetValue(document) as string ?? "(unnamed)"; } // Same shape as AetherDb's DanglingRefs / the migration's WalkAssetPathFields: plain fields, list // elements, and nested [MessagePackObject]/[Union] values (a HullData's Behaviors list, say). private static IEnumerable<(object Owner, FieldInfo Field)> WalkAssetGuidFields(object value, HashSet seen) { if (value == null || !seen.Add(value)) yield break; foreach (var field in value.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { if (field.FieldType == typeof(string)) { if (field.GetCustomAttribute() != null) yield return (value, field); continue; } var member = field.GetValue(value); if (member == null) continue; if (member is IDictionary dictionary) { foreach (var key in dictionary.Keys) { foreach (var nested in WalkAssetGuidFields(key, seen)) yield return nested; foreach (var nested in WalkAssetGuidFields(dictionary[key], seen)) yield return nested; } } else if (member is IList list && !(member is Array { Rank: > 1 })) { foreach (var element in list) if (element != null && IsSerializedObject(element.GetType())) foreach (var nested in WalkAssetGuidFields(element, seen)) yield return nested; } else if (IsSerializedObject(member.GetType())) { foreach (var nested in WalkAssetGuidFields(member, seen)) yield return nested; } } } private static bool IsSerializedObject(Type type) { for (var t = type; t != null && t != typeof(object); t = t.BaseType) if (t.IsDefined(typeof(MessagePackObjectAttribute), false) || t.IsDefined(typeof(UnionAttribute), false)) return true; return false; } }