Aetheria/Assets/Scripts/Editor/EngineAssetCheck.cs

206 lines
9.6 KiB
C#
Raw Permalink Normal View History

Switch engine asset references from Resources paths to Addressables GUIDs Cut 2 of docs/addressables-cut.md. The asset move (git mv Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a concurrent session sharing this working tree; this commit is the rest of Cut 2 landing on top of that shared state, atomically: - Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both 45c2f40) in Packages/manifest.json, packages-lock.json and Directory.Build.props, together with the 8 ServerShared [CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] - the .NET and Unity sides move together or nothing compiles. - Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an Addressables key (bare GUID or guid[subAsset]) synchronously, loading the GameObject and taking the component for the four sites that load a component. ActionBarSlot, EntityInstance, ShipInstance and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the inline "Assets/Resources/" prefix strips are gone with them. CultCacheDrawers' schematic preview goes through AssetDatabase.GUIDToAssetPath first. - Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored [CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file, is addressable, and satisfies its member's consumer-type contract (Texture2D for icons/logos/schematics, a GameObject with the right component for prefabs) - table-driven, so an attributed member with no row fails loudly instead of being skipped. - GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the pinned CultLib revision, a throwaway console app, never landed) walked all 138 attributed field slots across 180 documents, rewriting the 30 non-empty Resources paths to their .meta GUIDs - 108 stayed empty, 0 unresolved, matching the map's census exactly. Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean; EngineAssetCheck.Run passes all 30 stored references; the map's negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong type, non-addressable, random guid, an old path, and an untabled attributed member) are all caught and named, the control case and the post-revert recompile stay clean, and the catalog/source are restored byte-identical afterward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:04:50 +02:00
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<string, Contract> ConsumerContracts = new Dictionary<string, Contract>
{
["Icon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract<Texture2D>(asset, out reason),
["ActionBarIcon"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract<Texture2D>(asset, out reason),
["Schematic"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract<Texture2D>(asset, out reason),
["Logo"] = (object owner, Type ownerType, Object asset, out string reason) => TypeContract<Texture2D>(asset, out reason),
["Prefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract<EntityInstance>(asset as GameObject, out reason),
["ParticlesPrefab"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract<ParticleSystem>(asset as GameObject, out reason),
["Particles"] = (object owner, Type ownerType, Object asset, out string reason) => ComponentContract<ParticleSystem>(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<string> RunCheck(out int checkedCount)
{
var failures = new List<string>();
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<object>();
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<Object>(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<T>(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<T>(GameObject gameObject, out string reason) where T : Component
{
if (gameObject == null) { reason = "asset is not a GameObject"; return false; }
if (gameObject.GetComponent<T>() == 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<InstantWeaponEffectManager>(gameObject, out reason);
if (typeof(ConstantWeaponData).IsAssignableFrom(ownerType)) return ComponentContract<ConstantWeaponEffectManager>(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<object> 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<CultInspectorAssetGuidAttribute>() != 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;
}
}