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>
490 lines
22 KiB
C#
490 lines
22 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using GameCult.Caching;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using Object = UnityEngine.Object;
|
|
|
|
namespace GameCult.Unity.Caching.Editor
|
|
{
|
|
// An IMGUI drawer claimed through [CultInspectorDrawer]. A change returns the new value and sets GUI.changed, as IMGUI
|
|
// controls do; only a frame that reports a change is saved. Mutating value in place without GUI.changed is not saved
|
|
// reliably: it lingers in the edit copy until some later change saves it. A drawer that throws discards the whole
|
|
// frame's edit copy, so nothing it or any other drawer did that frame is saved. type is the value's declared type.
|
|
// member is the member the value belongs to, also for list elements and dictionary keys and values, and null for a
|
|
// bare value. inspector.DrawDefault hands the value back to built-in drawing; inspector.DrawValue draws a sub-value
|
|
// with claims. inspector.Record is the record being drawn, read-only: a copy nothing saves.
|
|
public interface ICultInspectorDrawer
|
|
{
|
|
object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member);
|
|
}
|
|
|
|
// The IMGUI lowering of CultInspectorModel. The model decides members, metadata, shapes, drawer claims and edit
|
|
// rules; this class draws them and hands every change back through the model.
|
|
public sealed class CultInspector
|
|
{
|
|
private static CultInspectorDrawerClaims _claims;
|
|
private static readonly Dictionary<Type, ICultInspectorDrawer> DrawerInstances = new Dictionary<Type, ICultInspectorDrawer>();
|
|
private static GUIStyle _errorStyle;
|
|
|
|
private readonly Dictionary<string, bool> _foldouts = new Dictionary<string, bool>(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, string> _notices = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
private string _path = string.Empty;
|
|
private CultInspectorEdit _edit;
|
|
private bool _drawerFailed;
|
|
|
|
internal CultInspector(CultInspectorModel model)
|
|
{
|
|
Model = model;
|
|
}
|
|
|
|
public CultInspectorModel Model { get; }
|
|
|
|
public IReadOnlyList<CultStoredDocument> Records { get; internal set; } = Array.Empty<CultStoredDocument>();
|
|
|
|
// The document being drawn, as the model's CultInspectorEdit.Record: a copy for reading, never saved.
|
|
public object Record => _edit?.Record;
|
|
|
|
private static GUIStyle ErrorStyle => _errorStyle ??= new GUIStyle(EditorStyles.label) { normal = { textColor = new Color(1f, .35f, .3f) } };
|
|
|
|
private static CultInspectorDrawerClaims Claims
|
|
{
|
|
get
|
|
{
|
|
if (_claims != null) return _claims;
|
|
_claims = new CultInspectorDrawerClaims(TypeCache.GetTypesWithAttribute<CultInspectorDrawerAttribute>(), typeof(ICultInspectorDrawer));
|
|
foreach (var error in _claims.Errors) Debug.LogError(error);
|
|
return _claims;
|
|
}
|
|
}
|
|
|
|
// False when a drawer threw: the edit copy may hold part of its in-place mutation and must be discarded, not saved.
|
|
internal bool DrawDocument(CultInspectorEdit edit)
|
|
{
|
|
_drawerFailed = false;
|
|
_edit = edit;
|
|
DrawMembers(edit.Document, edit.Source.Descriptor.DocumentType, edit.Source.Key.Value);
|
|
return !_drawerFailed;
|
|
}
|
|
|
|
// Draws a sub-value of the value being drawn, claims applied.
|
|
public object DrawValue(string label, Type type, object value, MemberInfo member)
|
|
{
|
|
return DrawValue(label, type, value, member, _path + "/" + label);
|
|
}
|
|
|
|
// Built-in drawing for a value, ignoring drawer claims on it.
|
|
public object DrawDefault(string label, Type type, object value, MemberInfo member)
|
|
{
|
|
var shape = Model.ShapeOf(type);
|
|
var metadata = Model.MetadataOf(member);
|
|
switch (shape.Kind)
|
|
{
|
|
case CultInspectorValueKind.String:
|
|
return DrawString(label, value as string, metadata);
|
|
case CultInspectorValueKind.Integer:
|
|
return DrawInteger(label, type, value, metadata);
|
|
case CultInspectorValueKind.Float:
|
|
if (type == typeof(double)) return EditorGUILayout.DoubleField(label, value is double d ? d : 0d);
|
|
var f = value is float single ? single : 0f;
|
|
return metadata.Range == null ? EditorGUILayout.FloatField(label, f) : EditorGUILayout.Slider(label, f, metadata.Range.Min, metadata.Range.Max);
|
|
case CultInspectorValueKind.Bool:
|
|
return EditorGUILayout.Toggle(label, value is bool b && b);
|
|
case CultInspectorValueKind.Enum:
|
|
var current = value as Enum ?? (Enum)Enum.ToObject(type, 0);
|
|
return type.IsDefined(typeof(FlagsAttribute), false) ? EditorGUILayout.EnumFlagsField(label, current) : EditorGUILayout.EnumPopup(label, current);
|
|
case CultInspectorValueKind.RecordRef:
|
|
return DrawRecordRef(label, type, value);
|
|
case CultInspectorValueKind.List:
|
|
return DrawList(label, shape, value, member);
|
|
case CultInspectorValueKind.Dictionary:
|
|
return DrawDictionary(label, shape, value as IDictionary, member);
|
|
case CultInspectorValueKind.Union:
|
|
return DrawUnion(label, shape, value);
|
|
case CultInspectorValueKind.Nested:
|
|
return DrawNested(label, type, value);
|
|
default:
|
|
return typeof(Object).IsAssignableFrom(type)
|
|
? EditorGUILayout.ObjectField(label, value as Object, type, false)
|
|
: ErrorRow(label, shape.Reason, value);
|
|
}
|
|
}
|
|
|
|
private object DrawValue(string label, Type type, object value, MemberInfo member, string path)
|
|
{
|
|
var outer = _path;
|
|
_path = path;
|
|
try
|
|
{
|
|
var claim = Claims.Resolve(type, member);
|
|
if (claim.Conflict != null) return ErrorRow(label, "no drawer: " + claim.Conflict, value);
|
|
if (claim.Drawer == null) return DrawDefault(label, type, value, member);
|
|
var changed = GUI.changed;
|
|
try
|
|
{
|
|
return Drawer(claim.Drawer).Draw(this, label, type, value, member);
|
|
}
|
|
catch (ExitGUIException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
GUI.changed = changed;
|
|
_drawerFailed = true;
|
|
return ErrorRow(label, claim.Drawer.Name + " failed: " + exception.Message + " Nothing edited this frame was saved.", value);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_path = outer;
|
|
}
|
|
}
|
|
|
|
private static ICultInspectorDrawer Drawer(Type type)
|
|
{
|
|
if (!DrawerInstances.TryGetValue(type, out var drawer))
|
|
DrawerInstances[type] = drawer = (ICultInspectorDrawer)Activator.CreateInstance(type);
|
|
return drawer;
|
|
}
|
|
|
|
private void DrawMembers(object target, Type type, string path)
|
|
{
|
|
foreach (var member in Model.MembersOf(type))
|
|
{
|
|
if (member.Metadata.Hidden) continue;
|
|
using (new EditorGUI.DisabledScope(member.IsReadOnly))
|
|
{
|
|
EditorGUI.BeginChangeCheck();
|
|
var next = DrawValue(LabelOf(member), member.ValueType, member.GetValue(target), member.Member, path + "." + member.Name);
|
|
if (EditorGUI.EndChangeCheck() && !member.IsReadOnly) member.SetValue(target, next);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string DrawString(string label, string value, CultInspectorMetadata metadata)
|
|
{
|
|
if (metadata.AssetGuid != null) return DrawAssetGuid(label, value, metadata.AssetGuid);
|
|
|
|
var textArea = metadata.TextArea;
|
|
if (textArea == null) return EditorGUILayout.TextField(label, value ?? string.Empty);
|
|
EditorGUILayout.LabelField(label);
|
|
return EditorGUILayout.TextArea(value ?? string.Empty,
|
|
GUILayout.MinHeight(Mathf.Max(textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight),
|
|
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);
|
|
if (type == typeof(int) && metadata.Range != null)
|
|
return EditorGUILayout.IntSlider(label, (int)current, Mathf.RoundToInt(metadata.Range.Min), Mathf.RoundToInt(metadata.Range.Max));
|
|
var next = EditorGUILayout.LongField(label, current);
|
|
return next == current && value != null ? value : Model.NarrowInteger(type, next);
|
|
}
|
|
|
|
private object DrawRecordRef(string label, Type type, object value)
|
|
{
|
|
var key = CultInspectorModel.RecordKey(value);
|
|
var candidates = Model.RecordCandidates(type, Records);
|
|
var index = 0;
|
|
var names = new string[candidates.Count + 1];
|
|
for (var i = 0; i < candidates.Count; i++)
|
|
{
|
|
names[i + 1] = CultInspectorModel.RecordLabel(candidates[i]);
|
|
if (candidates[i].Key.Value == key) index = i + 1;
|
|
}
|
|
|
|
names[0] = index == 0 && key.Length > 0 ? "Missing " + key : "None";
|
|
var next = key;
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
var picked = EditorGUILayout.Popup(label, index, names);
|
|
var indent = EditorGUI.indentLevel;
|
|
EditorGUI.indentLevel = 0;
|
|
var typed = EditorGUILayout.DelayedTextField(key, GUILayout.Width(120));
|
|
EditorGUI.indentLevel = indent;
|
|
if (picked != index) next = picked == 0 ? string.Empty : candidates[picked - 1].Key.Value;
|
|
else if (typed != key) next = typed;
|
|
}
|
|
|
|
return next == key ? value : Model.CreateRecordRef(type, next);
|
|
}
|
|
|
|
private object DrawDictionary(string label, CultInspectorShape shape, IDictionary dictionary, MemberInfo member)
|
|
{
|
|
var path = _path;
|
|
var entries = new List<KeyValuePair<object, object>>();
|
|
if (dictionary != null)
|
|
{
|
|
var enumerator = dictionary.GetEnumerator();
|
|
while (enumerator.MoveNext()) entries.Add(new KeyValuePair<object, object>(enumerator.Key, enumerator.Value));
|
|
}
|
|
|
|
if (!Foldout(path, label + " (" + entries.Count + ")")) return dictionary;
|
|
EditorGUI.indentLevel++;
|
|
var changed = false;
|
|
var remove = -1;
|
|
for (var i = 0; i < entries.Count; i++)
|
|
{
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
using (new EditorGUILayout.VerticalScope())
|
|
{
|
|
EditorGUI.BeginChangeCheck();
|
|
var key = DrawValue("Key", shape.KeyType, entries[i].Key, member, path + "{" + i + "}.key");
|
|
var item = DrawValue("Value", shape.ValueType, entries[i].Value, member, path + "{" + i + "}.value");
|
|
if (EditorGUI.EndChangeCheck())
|
|
{
|
|
key = Model.ReplaceKey(_edit.Document.GetType(), shape.Type, entries.Select(e => e.Key).ToArray(), i, key, out var notice);
|
|
Notice(path, notice);
|
|
entries[i] = new KeyValuePair<object, object>(key, item);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (GUILayout.Button("-", GUILayout.Width(20))) remove = i;
|
|
}
|
|
}
|
|
|
|
if (remove >= 0)
|
|
{
|
|
entries.RemoveAt(remove);
|
|
changed = true;
|
|
}
|
|
|
|
if (Add(shape.ValueType, path, out var created))
|
|
{
|
|
var fresh = Model.FreshKey(_edit.Document.GetType(), shape.Type, entries.Select(e => e.Key).ToArray(), Records, out var notice);
|
|
Notice(path, notice);
|
|
if (fresh != null)
|
|
{
|
|
entries.Add(new KeyValuePair<object, object>(fresh, created));
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
DrawNotice(path);
|
|
EditorGUI.indentLevel--;
|
|
return changed ? Model.BuildDictionary(shape.Type, entries) : dictionary;
|
|
}
|
|
|
|
private object DrawList(string label, CultInspectorShape shape, object value, MemberInfo member)
|
|
{
|
|
var path = _path;
|
|
var element = shape.ElementType;
|
|
var items = new List<object>();
|
|
if (value is IEnumerable enumerable)
|
|
foreach (var item in enumerable) items.Add(item);
|
|
|
|
if (!Foldout(path, label + " [" + items.Count + "]")) return value;
|
|
EditorGUI.indentLevel++;
|
|
var changed = false;
|
|
var remove = -1;
|
|
for (var i = 0; i < items.Count; i++)
|
|
{
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
using (new EditorGUILayout.VerticalScope())
|
|
{
|
|
EditorGUI.BeginChangeCheck();
|
|
var next = DrawValue("Element " + i, element, items[i], member, path + "[" + i + "]");
|
|
if (EditorGUI.EndChangeCheck())
|
|
{
|
|
items[i] = next;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (GUILayout.Button("-", GUILayout.Width(20))) remove = i;
|
|
}
|
|
}
|
|
|
|
if (remove >= 0)
|
|
{
|
|
items.RemoveAt(remove);
|
|
changed = true;
|
|
}
|
|
|
|
if (Add(element, path, out var created))
|
|
{
|
|
items.Add(created);
|
|
changed = true;
|
|
}
|
|
|
|
DrawNotice(path);
|
|
EditorGUI.indentLevel--;
|
|
return changed ? Model.BuildList(shape.Type, items) : value;
|
|
}
|
|
|
|
private object DrawUnion(string label, CultInspectorShape shape, object value)
|
|
{
|
|
var path = _path;
|
|
var choices = shape.UnionChoices;
|
|
var index = value == null ? 0 : IndexOf(choices, value.GetType()) + 1;
|
|
var names = choices.Select(u => u.Name).Prepend(value == null || index > 0 ? "None" : value.GetType().Name + " (not a declared union)").ToArray();
|
|
|
|
bool expanded;
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
expanded = Foldout(path, label, GUILayout.Width(EditorGUIUtility.labelWidth));
|
|
var indent = EditorGUI.indentLevel;
|
|
EditorGUI.indentLevel = 0;
|
|
var picked = EditorGUILayout.Popup(index, names);
|
|
EditorGUI.indentLevel = indent;
|
|
if (picked != index)
|
|
{
|
|
string notice = null;
|
|
value = picked == 0 ? null : Model.CreateElement(shape.Type, choices[picked - 1], out notice) ?? value;
|
|
Notice(path, notice);
|
|
}
|
|
}
|
|
|
|
DrawNotice(path);
|
|
if (!expanded || value == null) return value;
|
|
EditorGUI.indentLevel++;
|
|
DrawMembers(value, value.GetType(), path);
|
|
EditorGUI.indentLevel--;
|
|
return value;
|
|
}
|
|
|
|
private object DrawNested(string label, Type type, object value)
|
|
{
|
|
var path = _path;
|
|
if (value == null)
|
|
{
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
EditorGUILayout.LabelField(label, "null");
|
|
var created = Model.CreateDefault(type);
|
|
if (created != null && GUILayout.Button("Create", GUILayout.Width(56))) value = created;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
if (!Foldout(path, label)) return value;
|
|
EditorGUI.indentLevel++;
|
|
DrawMembers(value, type, path);
|
|
EditorGUI.indentLevel--;
|
|
return value;
|
|
}
|
|
|
|
// Foldouts stay usable inside disabled scopes so read-only data can still be browsed. Expanding is view state:
|
|
// it never counts as a change, so it never upserts.
|
|
private bool Foldout(string path, string label, params GUILayoutOption[] options)
|
|
{
|
|
_foldouts.TryGetValue(path, out var expanded);
|
|
var enabled = GUI.enabled;
|
|
var changed = GUI.changed;
|
|
GUI.enabled = true;
|
|
expanded = EditorGUI.Foldout(EditorGUILayout.GetControlRect(false, options), expanded, label, true);
|
|
GUI.enabled = enabled;
|
|
GUI.changed = changed;
|
|
_foldouts[path] = expanded;
|
|
return expanded;
|
|
}
|
|
|
|
// The add control under a list or dictionary: a button when the model offers the element type itself, a popup of
|
|
// a union's declared subtypes otherwise. True with the model's new value once one is picked and made.
|
|
private bool Add(Type elementType, string path, out object created)
|
|
{
|
|
created = null;
|
|
var choices = Model.ElementChoices(elementType);
|
|
Type choice;
|
|
if (choices.Count == 1 && choices[0] == elementType)
|
|
{
|
|
choice = GUILayout.Button("Add") ? elementType : null;
|
|
}
|
|
else
|
|
{
|
|
var picked = EditorGUILayout.Popup("Add", 0, choices.Select(c => c.Name).Prepend("...").ToArray());
|
|
choice = picked > 0 ? choices[picked - 1] : null;
|
|
}
|
|
|
|
if (choice == null) return false;
|
|
created = Model.CreateElement(elementType, choice, out var notice);
|
|
Notice(path, notice);
|
|
return notice == null;
|
|
}
|
|
|
|
// The model's notice for the control at path, kept until that control's next answer.
|
|
private void Notice(string path, string notice)
|
|
{
|
|
if (notice == null) _notices.Remove(path);
|
|
else _notices[path] = notice;
|
|
}
|
|
|
|
private void DrawNotice(string path)
|
|
{
|
|
if (_notices.TryGetValue(path, out var notice)) EditorGUILayout.HelpBox(notice, MessageType.Warning);
|
|
}
|
|
|
|
private static object ErrorRow(string label, string message, object value)
|
|
{
|
|
EditorGUILayout.LabelField(label, message, ErrorStyle);
|
|
return value;
|
|
}
|
|
|
|
private static int IndexOf(IReadOnlyList<Type> types, Type type)
|
|
{
|
|
for (var i = 0; i < types.Count; i++)
|
|
if (types[i] == type) return i;
|
|
return -1;
|
|
}
|
|
|
|
private static string LabelOf(CultInspectorMember member) => member.Metadata.Label ?? ObjectNames.NicifyVariableName(member.Name);
|
|
}
|
|
}
|