Studio: refuse built-ins by asset identity, not GUID digits; add Clear for stale keys

TrimEnd('0','f') missed Library/unity default resources built-ins whose GUID
has no all-zero or all-f suffix (e.g. 0000000000000000e000000000000000, the
LegacyRuntime font / Cube mesh in 6000.3.24f1). Ask AssetDatabase instead: a
real project asset is always a main or sub-asset under Assets/ or Packages/;
built-ins are neither. Also add a Clear button beside the stale-value warning
so a non-resolving stored key can be cleared without picking a substitute
asset first (drawing still never rewrites a stored value on its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-17 20:09:53 +02:00
commit 6c7cfa7a94
3 changed files with 86 additions and 10 deletions

View file

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

View file

@ -167,8 +167,10 @@ public sealed class ItemData
`AssetReference` keeps and `Addressables.LoadAssetAsync<T>` 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

View file

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