Studio: read and write sub-asset Addressables keys, warn on stale values

Ruling S (2026-09-17): a stored [CultInspectorAssetGuid] value is the
Addressables key form itself, the bare GUID for a main asset or
"guid[subAssetName]" for a picked sub-asset (one sprite of a sheet, say) -
the same shape AssetReference keeps and Addressables.LoadAssetAsync<T>
accepts.

CultAssetGuidKey (engine-free, GameCult.Caching) pins the parse/format rule
with unit tests (bare guid, guid[name], names with brackets/spaces,
malformed input), so headless checks can share it without touching Unity.

The Studio drawer's AssetGuid branch now: resolves either key form on read;
refuses to store a built-in resource's GUID (it has no Addressables entry)
and logs why instead of silently writing nothing; shows a warning next to a
stored value that fails to parse or resolve instead of drawing it as an
ordinary empty field, and never rewrites that stale value on its own.

Rebuilt GameCult.Caching/GameCult.Caching.MessagePack plugin DLLs
(scripts/build-unity-package.ps1 -UpdateTemplate) since the new type ships
in GameCult.Caching. Compiled CultCacheStudioDrawers.cs and
CultCacheStudioWindow.cs against Unity 6000.3.24f1's editor reference set
plus these plugins in a scratch csproj (removed after): 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-17 19:48:45 +02:00
commit 1e398e0116
9 changed files with 177 additions and 10 deletions

View file

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

View file

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

View file

@ -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<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)
- `CultInspectorDrawer` (on drawer classes)
CultMesh collaboration should feed the same CultCache mutation surface rather

View file

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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5b590f45dfef47cf9bc9a8e4dfa4038a
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: