diff --git a/src/GameCult.Caching/CultAssetGuidKey.cs b/src/GameCult.Caching/CultAssetGuidKey.cs new file mode 100644 index 00000000..aa36abbc --- /dev/null +++ b/src/GameCult.Caching/CultAssetGuidKey.cs @@ -0,0 +1,39 @@ +#nullable enable +namespace GameCult.Caching +{ + // The Addressables key form a [CultInspectorAssetGuid] member stores: the bare GUID for a main + // asset, or "guid[subAssetName]" when a sub-asset (one sprite of a sheet, say) is picked. This is + // Addressables' own key shape (what AssetReference keeps as GUID plus sub-object name); engine-free + // so both the Studio drawer and any headless check can pin the same parse without touching Unity. + public static class CultAssetGuidKey + { + public static bool TryParse(string key, out string guid, out string? subAssetName) + { + guid = string.Empty; + subAssetName = null; + if (string.IsNullOrEmpty(key)) return false; + + var bracket = key.IndexOf('['); + if (bracket < 0) + { + guid = key; + return true; + } + + if (bracket == 0 || key[key.Length - 1] != ']') return false; + + var name = key.Substring(bracket + 1, key.Length - bracket - 2); + if (name.Length == 0) return false; + + guid = key.Substring(0, bracket); + subAssetName = name; + return true; + } + + public static string Format(string guid, string? subAssetName) + { + guid ??= string.Empty; + return string.IsNullOrEmpty(subAssetName) ? guid : guid + "[" + subAssetName + "]"; + } + } +} diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs index ab1b1e84..8ffbee05 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -167,14 +167,7 @@ namespace GameCult.Unity.Caching.Editor private static string DrawString(string label, string value, CultInspectorMetadata metadata) { - if (metadata.AssetGuid != null) - { - var assetType = metadata.AssetGuid.AssetType != null && typeof(Object).IsAssignableFrom(metadata.AssetGuid.AssetType) ? metadata.AssetGuid.AssetType : typeof(Object); - var path = string.IsNullOrEmpty(value) ? string.Empty : AssetDatabase.GUIDToAssetPath(value); - var asset = string.IsNullOrEmpty(path) ? null : AssetDatabase.LoadAssetAtPath(path, assetType); - var next = EditorGUILayout.ObjectField(label, asset, assetType, false); - return next == asset ? value : next == null ? string.Empty : AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(next)); - } + if (metadata.AssetGuid != null) return DrawAssetGuid(label, value, metadata.AssetGuid); var textArea = metadata.TextArea; if (textArea == null) return EditorGUILayout.TextField(label, value ?? string.Empty); @@ -184,6 +177,55 @@ 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; + + // 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 + // stored value on its own — a value that fails to parse or resolve stays exactly as stored, with + // a warning shown alongside it, until the operator picks a new asset. + private static string DrawAssetGuid(string label, string value, CultInspectorAssetGuidAttribute attribute) + { + var assetType = attribute.AssetType != null && typeof(Object).IsAssignableFrom(attribute.AssetType) ? attribute.AssetType : typeof(Object); + Object asset = null; + var stale = false; + + if (!string.IsNullOrEmpty(value)) + { + if (CultAssetGuidKey.TryParse(value, out var guid, out var subAssetName)) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (!string.IsNullOrEmpty(path)) + { + asset = subAssetName == null + ? AssetDatabase.LoadAssetAtPath(path, assetType) + : AssetDatabase.LoadAllAssetRepresentationsAtPath(path) + .FirstOrDefault(candidate => candidate != null && candidate.name == subAssetName && assetType.IsInstanceOfType(candidate)); + } + } + + stale = asset == null; + } + + if (stale) EditorGUILayout.HelpBox(label + ": stored value \"" + value + "\" does not resolve to an asset.", MessageType.Warning); + + 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)) + { + Debug.LogWarning(label + ": \"" + next.name + "\" is a built-in resource with no Addressables GUID. Not stored."); + return value; + } + + return CultAssetGuidKey.Format(nextGuid, AssetDatabase.IsSubAsset(next) ? next.name : null); + } + private object DrawInteger(string label, Type type, object value, CultInspectorMetadata metadata) { var current = value == null ? 0L : Convert.ToInt64(value); diff --git a/src/GameCult.Unity/Assets/Caching/README.md b/src/GameCult.Unity/Assets/Caching/README.md index 49348452..6b5097dc 100644 --- a/src/GameCult.Unity/Assets/Caching/README.md +++ b/src/GameCult.Unity/Assets/Caching/README.md @@ -162,8 +162,13 @@ public sealed class ItemData - `CultInspectorTextArea` - `CultInspectorRange` - `CultInspectorAssetGuid` (a string member holding an engine asset's stable - identity, in Unity the `.meta` GUID; optional engine asset `Type`, named by - the consumer; the Studio uses it when it is a `UnityEngine.Object` type) + identity: in Unity, the `.meta` GUID for a main asset, or `guid[subAssetName]` + when a sub-asset such as one sprite of a sheet is picked — the same key form + `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) - `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 new file mode 100644 index 00000000..e8f6d055 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultAssetGuidKeyTests.cs @@ -0,0 +1,74 @@ +#nullable enable +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public class CultAssetGuidKeyTests + { + [Test] + public void BareGuidParsesWithNoSubAsset() + { + Assert.That(CultAssetGuidKey.TryParse("0123456789abcdef0123456789abcdef", out var guid, out var sub), Is.True); + Assert.That(guid, Is.EqualTo("0123456789abcdef0123456789abcdef")); + Assert.That(sub, Is.Null); + } + + [Test] + public void GuidWithSubAssetNameParses() + { + 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 SubAssetNameMayContainSpaces() + { + Assert.That(CultAssetGuidKey.TryParse("abc123[Icon Small Two]", out var guid, out var sub), Is.True); + Assert.That(guid, Is.EqualTo("abc123")); + Assert.That(sub, Is.EqualTo("Icon Small Two")); + } + + [Test] + public void SubAssetNameMayContainBrackets() + { + Assert.That(CultAssetGuidKey.TryParse("abc123[Icon[2]]", out var guid, out var sub), Is.True); + Assert.That(guid, Is.EqualTo("abc123")); + Assert.That(sub, Is.EqualTo("Icon[2]")); + } + + [TestCase("")] + [TestCase("[name]")] + [TestCase("abc123[")] + [TestCase("abc123[name")] + [TestCase("abc123]")] + [TestCase("abc123[]")] + public void MalformedInputDoesNotParse(string key) + { + var ok = CultAssetGuidKey.TryParse(key, out var guid, out var sub); + if (key == "abc123]") + { + // No '[' at all: treated as a literal bare key, not a bracket form. + Assert.That(ok, Is.True); + Assert.That(guid, Is.EqualTo("abc123]")); + Assert.That(sub, Is.Null); + return; + } + + Assert.That(ok, Is.False); + } + + [Test] + public void FormatRoundTripsBareGuid() + { + Assert.That(CultAssetGuidKey.Format("abc123", null), Is.EqualTo("abc123")); + Assert.That(CultAssetGuidKey.Format("abc123", string.Empty), Is.EqualTo("abc123")); + } + + [Test] + public void FormatRoundTripsSubAsset() + { + Assert.That(CultAssetGuidKey.Format("abc123", "Icon_Small"), Is.EqualTo("abc123[Icon_Small]")); + } + } +} diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll index 3909f727..0f8bf10f 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb index bdf4a29f..6d3558d5 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll index d2df5761..1ad9d1ad 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll differ diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb index 7643cc0a..4e1090db 100644 Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb differ diff --git a/unity/org.gamecult.cultlib/Third Party Notices/OPENSSL-NOTICE.txt.meta b/unity/org.gamecult.cultlib/Third Party Notices/OPENSSL-NOTICE.txt.meta new file mode 100644 index 00000000..9a4cbba7 --- /dev/null +++ b/unity/org.gamecult.cultlib/Third Party Notices/OPENSSL-NOTICE.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5b590f45dfef47cf9bc9a8e4dfa4038a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: