diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs index 8ffbee05..8f008f24 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -177,11 +177,20 @@ namespace GameCult.Unity.Caching.Editor GUILayout.MaxHeight(Mathf.Max(textArea.MaxLines, textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight)); } - // Every zero-hex GUID that AssetDatabase uses for built-in/generated resources (the ordinary - // "all zero" form and the "...f000000000000000" virtual-asset form) has no .meta file and no - // Addressables entry, so it cannot be stored as a key. - private static bool IsBuiltinResourceGuid(string guid) => - !string.IsNullOrEmpty(guid) && guid.TrimEnd('0', 'f') == string.Empty; + // A GUID-string heuristic ("all zero", "...f000...") cannot cover every built-in Unity ships: + // "Library/unity default resources" built-ins (verified in 6000.3.24f1, e.g. LegacyRuntime font + // and the Cube mesh under GUID 0000000000000000e000000000000000) carry ordinary-looking hex GUIDs + // with no all-zero or all-f suffix. Ask AssetDatabase about the object instead: a real project + // asset is always either the main asset at its path or one of its sub-assets, and always lives + // under Assets/ or Packages/. Built-ins are neither (verified False for both IsMainAsset and + // IsSubAsset) and resolve to no path under either root. + private static bool IsBuiltinResource(Object asset) + { + if (asset == null) return false; + if (AssetDatabase.IsMainAsset(asset) || AssetDatabase.IsSubAsset(asset)) return false; + var path = AssetDatabase.GetAssetPath(asset); + return string.IsNullOrEmpty(path) || !(path.StartsWith("Assets/", StringComparison.Ordinal) || path.StartsWith("Packages/", StringComparison.Ordinal)); + } // Reads and writes the Addressables key form ruled in docs/addressables-cut.md (operator ruling S): // the bare GUID for a main asset, "guid[subAssetName]" for a picked sub-asset. Never rewrites a @@ -210,19 +219,26 @@ namespace GameCult.Unity.Caching.Editor stale = asset == null; } - if (stale) EditorGUILayout.HelpBox(label + ": stored value \"" + value + "\" does not resolve to an asset.", MessageType.Warning); + if (stale) + { + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.HelpBox(label + ": stored value \"" + value + "\" does not resolve to an asset.", MessageType.Warning); + if (GUILayout.Button("Clear", GUILayout.Width(48), GUILayout.Height(EditorGUIUtility.singleLineHeight))) return string.Empty; + } + } var next = EditorGUILayout.ObjectField(label, asset, assetType, false); if (next == asset) return value; if (next == null) return string.Empty; - var nextGuid = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(next)); - if (string.IsNullOrEmpty(nextGuid) || IsBuiltinResourceGuid(nextGuid)) + if (IsBuiltinResource(next)) { Debug.LogWarning(label + ": \"" + next.name + "\" is a built-in resource with no Addressables GUID. Not stored."); return value; } + var nextGuid = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(next)); return CultAssetGuidKey.Format(nextGuid, AssetDatabase.IsSubAsset(next) ? next.name : null); } diff --git a/src/GameCult.Unity/Assets/Caching/README.md b/src/GameCult.Unity/Assets/Caching/README.md index 6b5097dc..7f1d7ab6 100644 --- a/src/GameCult.Unity/Assets/Caching/README.md +++ b/src/GameCult.Unity/Assets/Caching/README.md @@ -167,8 +167,10 @@ public sealed class ItemData `AssetReference` keeps and `Addressables.LoadAssetAsync` accepts; optional engine asset `Type`, named by the consumer; the Studio uses it when it is a `UnityEngine.Object` type, refuses to store a built-in resource's GUID since - it has no Addressables entry, and warns rather than silently clearing a - stored value that no longer resolves) + it has no Addressables entry (checked by asking AssetDatabase whether the + picked object is a main or sub-asset under `Assets/` or `Packages/`, not by + guessing from the GUID's hex digits), and never silently clears a stored + value that no longer resolves — it warns and offers a Clear button instead) - `CultInspectorDrawer` (on drawer classes) CultMesh collaboration should feed the same CultCache mutation surface rather diff --git a/tests/GameCult.Caching.Tests/CultAssetGuidKeyTests.cs b/tests/GameCult.Caching.Tests/CultAssetGuidKeyTests.cs index e8f6d055..4838fd86 100644 --- a/tests/GameCult.Caching.Tests/CultAssetGuidKeyTests.cs +++ b/tests/GameCult.Caching.Tests/CultAssetGuidKeyTests.cs @@ -70,5 +70,63 @@ namespace GameCult.Caching.Tests { Assert.That(CultAssetGuidKey.Format("abc123", "Icon_Small"), Is.EqualTo("abc123[Icon_Small]")); } + + [Test] + public void SubAssetNameWithLeadingAndTrailingSpacesRoundTrips() + { + // A name.Trim() mutant would strip the spaces on the way out; Addressables sub-asset + // names are taken verbatim, so the exact bytes between the brackets must survive. + Assert.That(CultAssetGuidKey.TryParse("abc123[ Icon Small ]", out var guid, out var sub), Is.True); + Assert.That(guid, Is.EqualTo("abc123")); + Assert.That(sub, Is.EqualTo(" Icon Small ")); + } + + [Test] + public void FormatThenTryParseRoundTripsBareGuid() + { + var key = CultAssetGuidKey.Format("abc123", null); + Assert.That(CultAssetGuidKey.TryParse(key, out var guid, out var sub), Is.True); + Assert.That(guid, Is.EqualTo("abc123")); + Assert.That(sub, Is.Null); + } + + [Test] + public void FormatThenTryParseRoundTripsSubAsset() + { + var key = CultAssetGuidKey.Format("abc123", "Icon[2]"); + Assert.That(CultAssetGuidKey.TryParse(key, out var guid, out var sub), Is.True); + Assert.That(guid, Is.EqualTo("abc123")); + Assert.That(sub, Is.EqualTo("Icon[2]")); + } + + [Test] + public void FormatOfNullOrEmptyGuidReturnsEmptyString() + { + Assert.That(CultAssetGuidKey.Format(null!, null), Is.EqualTo(string.Empty)); + Assert.That(CultAssetGuidKey.Format(string.Empty, null), Is.EqualTo(string.Empty)); + Assert.That(CultAssetGuidKey.Format(null!, "Icon"), Is.EqualTo("[Icon]")); + } + + [Test] + public void BareGuidPreservesCase() + { + // No lowercasing: a mutant that normalizes case would still pass every other test here + // since the fixtures above are already lowercase. + Assert.That(CultAssetGuidKey.TryParse("ABC123DEF456", out var guid, out _), Is.True); + Assert.That(guid, Is.EqualTo("ABC123DEF456")); + } + + [Test] + public void FormatOfSubAssetNameContainingBracketsMatchesAddressablesSplit() + { + // Documents the split ruled for Addressables-shaped keys: "g[Icon[2]]" parses back to + // name "Icon[2]", i.e. Format takes everything up to the FIRST '[' as the guid and + // everything between it and the LAST ']' as the name, brackets included. + var key = CultAssetGuidKey.Format("g", "Icon[2]"); + Assert.That(key, Is.EqualTo("g[Icon[2]]")); + Assert.That(CultAssetGuidKey.TryParse(key, out var guid, out var sub), Is.True); + Assert.That(guid, Is.EqualTo("g")); + Assert.That(sub, Is.EqualTo("Icon[2]")); + } } }