Fix Cut 6 Studio findings: edit a copy, honest New, contested drawers

Edits: drawers now mutate a MessagePack clone of the selected record.
The cached object changes only through an admitted UpsertAsync; after
any upsert attempt the copy is dropped and re-cloned from the cache, so
a refused upsert leaves memory untouched (covers member Set, list and
dictionary in-place rebuilds). Duplicate uses the same clone helper.

Foldouts restore GUI.changed, so expanding never upserts or dirties.

Extension point: Draw is wrapped; ExitGUIException rethrows, anything
else draws an error row and returns the value unchanged. A type claimed
by two or more drawers is claimed by none; every claimant is logged and
the member draws the red row naming them.

Multi-dimensional arrays (float[,], bool2[,]) draw an error row and are
never flattened into a rank-1 array.

Dictionary keys compare by serialized identity; CultRecordRef<T> keys
compare by key string with null and "" the same empty key. Null,
empty-ref and duplicate keys are refused with a visible warning. Add
picks the first unused record for ref keys and warns when none is left.

Union popup shows "None" for null and never labels a declared subtype
as undeclared.

New creates only a writable single-file store: disabled with Read Only
or Directory toggled, refuses an existing path (file or .records
folder) without closing the open store, and reports Created only after
the file exists on disk.

CultCache.FlushAsync(bool soft) kept: Mimir.CultMeshMedia calls
cache.FlushAsync(soft: true) on a CultCache through a project reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 13:18:03 +02:00
commit 131c508a73
2 changed files with 159 additions and 27 deletions

View file

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using UnityEditor;
using UnityEngine;
@ -21,12 +22,14 @@ namespace GameCult.Unity.Caching.Editor
internal sealed class CultInspector
{
private static Dictionary<Type, ICultInspectorDrawer> _drawers;
private static Dictionary<Type, string> _conflicts;
private static readonly Dictionary<Type, MemberInfo> NameMembers = new Dictionary<Type, MemberInfo>();
private static GUIStyle _errorStyle;
private readonly CultCache _cache;
private readonly Dictionary<string, bool> _foldouts = new Dictionary<string, bool>(StringComparer.Ordinal);
private readonly Dictionary<Type, MemberInfo[]> _members = new Dictionary<Type, MemberInfo[]>();
private readonly Dictionary<string, string> _notices = new Dictionary<string, string>(StringComparer.Ordinal);
public CultInspector(CultCache cache)
{
@ -43,15 +46,32 @@ namespace GameCult.Unity.Caching.Editor
{
if (_drawers != null) return _drawers;
_drawers = new Dictionary<Type, ICultInspectorDrawer>();
_conflicts = new Dictionary<Type, string>();
foreach (var type in TypeCache.GetTypesWithAttribute<CultInspectorDrawerAttribute>())
{
var memberType = type.GetCustomAttribute<CultInspectorDrawerAttribute>().MemberType;
if (!typeof(ICultInspectorDrawer).IsAssignableFrom(type) || type.IsAbstract || type.GetConstructor(Type.EmptyTypes) == null)
{
Debug.LogError($"{type.FullName} is marked [CultInspectorDrawer] but is not a concrete ICultInspectorDrawer with a parameterless constructor.");
else if (_drawers.ContainsKey(memberType))
Debug.LogError($"{type.FullName} and {_drawers[memberType].GetType().FullName} both draw {memberType.FullName}.");
continue;
}
// A contested type is claimed by nobody: it draws the red row until one claimant is removed.
if (_drawers.TryGetValue(memberType, out var claimed))
{
_drawers.Remove(memberType);
_conflicts[memberType] = claimed.GetType().FullName;
}
if (_conflicts.TryGetValue(memberType, out var claimants))
{
_conflicts[memberType] = claimants + ", " + type.FullName;
Debug.LogError($"{_conflicts[memberType]} all draw {memberType.FullName}; none of them is used.");
}
else
{
_drawers[memberType] = (ICultInspectorDrawer)Activator.CreateInstance(type);
}
}
return _drawers;
@ -73,15 +93,36 @@ namespace GameCult.Unity.Caching.Editor
return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name;
}
public void DrawDocument(CultStoredDocument record)
// Draws into the document it is given and mutates it in place; the caller hands over a working copy.
public void DrawDocument(object document, Type type, string key)
{
DrawMembers(record.Document, record.Descriptor.DocumentType, record.Key.Value);
DrawMembers(document, type, key);
}
public object DrawValue(string label, Type type, object value, MemberInfo member, string path)
{
if (Drawers.TryGetValue(type, out var drawer) || type.IsGenericType && Drawers.TryGetValue(type.GetGenericTypeDefinition(), out drawer))
return drawer.Draw(label, value, member);
{
var changed = GUI.changed;
try
{
return drawer.Draw(label, value, member);
}
catch (ExitGUIException)
{
throw;
}
catch (Exception exception)
{
GUI.changed = changed;
return ErrorRow(label, drawer.GetType().Name + " failed: " + exception.Message, value);
}
}
if (_conflicts.TryGetValue(type, out var claimants) || type.IsGenericType && _conflicts.TryGetValue(type.GetGenericTypeDefinition(), out claimants))
return ErrorRow(label, "no drawer for " + type.FullName + " (contested by " + claimants + ")", value);
if (type.IsArray && type.GetArrayRank() != 1)
return ErrorRow(label, type.Name + " is a multi-dimensional array; the Studio cannot edit it", value);
if (type == typeof(string)) return DrawString(label, value as string, member);
var range = member?.GetCustomAttribute<CultInspectorRangeAttribute>();
@ -269,7 +310,24 @@ namespace GameCult.Unity.Caching.Editor
if (EditorGUI.EndChangeCheck())
{
var index = i;
if (key == null || entries.Where((_, j) => j != index).Any(e => Equals(e.Key, key))) key = entries[i].Key;
var identity = KeyIdentity(key);
if (identity != KeyIdentity(entries[i].Key))
{
var refusal = identity == null ? "a null key"
: identity == EmptyRefIdentity ? "an empty record reference as a key"
: entries.Where((_, j) => j != index).Any(e => KeyIdentity(e.Key) == identity) ? "a duplicate key"
: null;
if (refusal != null)
{
_notices[path] = "Refused " + refusal + " on entry " + i + "; its key was kept.";
key = entries[i].Key;
}
else
{
_notices.Remove(path);
}
}
entries[i] = new DictionaryEntry(key, item);
changed = true;
}
@ -285,16 +343,22 @@ namespace GameCult.Unity.Caching.Editor
changed = true;
}
var fresh = CreateDefault(arguments[0]);
using (new EditorGUI.DisabledScope(fresh == null || entries.Any(e => Equals(e.Key, fresh))))
if (GUILayout.Button("Add"))
{
if (GUILayout.Button("Add"))
var fresh = FreshKey(arguments[0], entries);
if (fresh == null)
{
_notices[path] = "No unused " + arguments[0].Name + " key is available; nothing was added.";
}
else
{
entries.Add(new DictionaryEntry(fresh, CreateDefault(arguments[1])));
_notices.Remove(path);
changed = true;
}
}
if (_notices.TryGetValue(path, out var notice)) EditorGUILayout.HelpBox(notice, MessageType.Warning);
EditorGUI.indentLevel--;
if (!changed) return dictionary;
var result = dictionary ?? (IDictionary)Activator.CreateInstance(type.IsInterface || type.IsAbstract
@ -305,6 +369,39 @@ namespace GameCult.Unity.Caching.Editor
return result;
}
private const string EmptyRefIdentity = "ref:";
// Keys are the same key when they serialize the same; a CultRecordRef<T> is its key string, null and "" alike.
private static string KeyIdentity(object key)
{
if (key == null) return null;
var type = key.GetType();
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) return EmptyRefIdentity + key;
try
{
return Convert.ToBase64String(MessagePackSerializer.Serialize(type, key, CultDocumentMessagePackSerialization.Options));
}
catch (Exception)
{
return type.FullName + ":" + key;
}
}
// A record-reference key takes the first record of its type not already used; nothing else invents a key.
private object FreshKey(Type type, List<DictionaryEntry> entries)
{
var used = new HashSet<string>(entries.Select(e => KeyIdentity(e.Key)), StringComparer.Ordinal);
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
{
var target = type.GetGenericArguments()[0];
var record = Records.FirstOrDefault(r => target.IsInstanceOfType(r.Document) && r.Key.Value.Length > 0 && !used.Contains(EmptyRefIdentity + r.Key.Value));
return record == null ? null : Activator.CreateInstance(type, new CultRecordKey(record.Key.Value));
}
var fresh = CreateDefault(type);
return fresh == null || used.Contains(KeyIdentity(fresh)) ? null : fresh;
}
private object DrawList(string label, Type type, Type element, object value, MemberInfo member, string path)
{
var items = new List<object>();
@ -376,7 +473,7 @@ namespace GameCult.Unity.Caching.Editor
var unions = UnionsOf(type);
if (unions.Length == 0) return ErrorRow(label, type.Name + " declares no [Union] subtypes", value);
var index = value == null ? 0 : Array.IndexOf(unions, value.GetType()) + 1;
var names = unions.Select(u => u.Name).Prepend(value == null ? "None" : value.GetType().Name + " (not a declared union)").ToArray();
var names = unions.Select(u => u.Name).Prepend(value == null || index > 0 ? "None" : value.GetType().Name + " (not a declared union)").ToArray();
bool expanded;
using (new EditorGUILayout.HorizontalScope())
@ -453,14 +550,17 @@ namespace GameCult.Unity.Caching.Editor
return new CM.bool2(x, y);
}
// Foldouts stay usable inside disabled scopes so read-only data can still be browsed.
// 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;
}
@ -481,7 +581,7 @@ namespace GameCult.Unity.Caching.Editor
private static Type ListElement(Type type)
{
if (type.IsArray) return type.GetElementType();
if (type.IsArray) return type.GetArrayRank() == 1 ? type.GetElementType() : null;
return type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type)
? type.GetGenericArguments()[0]
: null;

View file

@ -21,6 +21,8 @@ namespace GameCult.Unity.Caching.Editor
private string _search = string.Empty;
private Type _selectedType;
private string _selectedKey;
private CultStoredDocument _editSource;
private object _editCopy;
private Vector2 _typeScroll;
private Vector2 _recordScroll;
private Vector2 _inspectorScroll;
@ -72,7 +74,12 @@ namespace GameCult.Unity.Caching.Editor
_directory = GUILayout.Toggle(_directory, "Directory", EditorStyles.toolbarButton, GUILayout.Width(62));
_readOnly = GUILayout.Toggle(_readOnly, "Read Only", EditorStyles.toolbarButton, GUILayout.Width(66));
if (GUILayout.Button("Open", EditorStyles.toolbarButton, GUILayout.Width(44))) Browse(false);
if (GUILayout.Button("New", EditorStyles.toolbarButton, GUILayout.Width(40))) Browse(true);
using (new EditorGUI.DisabledScope(_readOnly || _directory))
{
if (GUILayout.Button(new GUIContent("New", "Creates a writable single-file store. Turn off Read Only and Directory to enable."),
EditorStyles.toolbarButton, GUILayout.Width(40)))
Browse(true);
}
using (new EditorGUI.DisabledScope(_cache == null))
{
if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(52)) && ConfirmDiscard()) OpenStore(_path, false);
@ -196,13 +203,26 @@ namespace GameCult.Unity.Caching.Editor
EditorGUILayout.LabelField(record.Descriptor.SchemaName + " " + record.Descriptor.DocumentType.Name, EditorStyles.boldLabel);
EditorGUILayout.SelectableLabel("Key " + record.Key.Value + " Stored " + record.StoredAt, EditorStyles.miniLabel, GUILayout.Height(EditorGUIUtility.singleLineHeight));
_inspectorScroll = EditorGUILayout.BeginScrollView(_inspectorScroll);
// Drawers edit a copy. The cached object changes only through an admitted upsert; after any upsert
// attempt the copy is dropped and re-cloned from whatever the cache now holds.
var type = record.Descriptor.DocumentType;
if (!ReferenceEquals(_editSource, record))
{
_editSource = record;
_editCopy = Clone(record.Document, type);
}
var readOnly = ReadOnly;
using (new EditorGUI.DisabledScope(readOnly))
{
EditorGUI.BeginChangeCheck();
_inspector.DrawDocument(record);
_inspector.DrawDocument(_editCopy, type, record.Key.Value);
if (EditorGUI.EndChangeCheck() && !readOnly)
Run(null, () => _cache.UpsertAsync(record.Descriptor.DocumentType, record.Document, record.Key).GetAwaiter().GetResult());
{
var edited = _editCopy;
_editSource = null;
Run(null, () => _cache.UpsertAsync(type, edited, record.Key).GetAwaiter().GetResult());
}
}
EditorGUILayout.EndScrollView();
@ -225,24 +245,35 @@ namespace GameCult.Unity.Caching.Editor
GUIUtility.ExitGUI();
}
// New writes an empty single-file store and never touches an existing path; directory stores and read-only
// opens are refused because neither can write a store on creation.
private void OpenStore(string path, bool create)
{
CloseStore();
if (!create && !File.Exists(path))
var exists = File.Exists(path) || Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path));
string refusal = null;
if (create && (_readOnly || _directory)) refusal = "New creates writable single-file stores only; turn off Read Only and Directory. Nothing was created.";
else if (create && exists) refusal = path + " already exists; nothing was created or replaced. Use Open to edit it.";
else if (!create && !File.Exists(path)) refusal = "No store at " + path + ".";
if (refusal != null)
{
SetStatus("No store at " + path + ".", MessageType.Error);
SetStatus(refusal, MessageType.Error);
return;
}
CloseStore();
try
{
var existed = File.Exists(path);
_cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { ReadOnly = _readOnly, UseDirectoryStore = _directory });
if (create && !existed && !_readOnly) _cache.BackingStores[0].PushAll();
_cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { ReadOnly = !create && _readOnly, UseDirectoryStore = !create && _directory });
if (create)
{
_cache.BackingStores[0].PushAll();
if (!File.Exists(path)) throw new IOException("the store wrote no file");
}
_inspector = new CultInspector(_cache);
_path = path;
EditorPrefs.SetString(LastPathKey, path);
SetStatus((create && !existed ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : "."), MessageType.Info);
SetStatus((create ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : "."), MessageType.Info);
}
catch (Exception exception)
{
@ -258,6 +289,8 @@ namespace GameCult.Unity.Caching.Editor
_inspector = null;
_records = Array.Empty<CultStoredDocument>();
_selectedKey = null;
_editSource = null;
_editCopy = null;
}
private bool ConfirmDiscard()
@ -282,14 +315,13 @@ namespace GameCult.Unity.Caching.Editor
{
var type = record.Descriptor.DocumentType;
Run("Duplicated " + CultInspector.RecordLabel(record) + ".", () =>
{
var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(record.Document, type, _cache.Registry);
var copy = CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, _cache.Registry);
_selectedKey = _cache.UpsertAsync(type, copy).GetAwaiter().GetResult().Value;
});
_selectedKey = _cache.UpsertAsync(type, Clone(record.Document, type)).GetAwaiter().GetResult().Value);
GUIUtility.ExitGUI();
}
private object Clone(object document, Type type) =>
CultDocumentMessagePackSerialization.DeserializeUntyped(type, CultDocumentMessagePackSerialization.SerializeUntyped(document, type, _cache.Registry), _cache.Registry);
private void Delete(CultStoredDocument record)
{
var label = CultInspector.RecordLabel(record);